diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9fe32b4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.java linguist-language=Scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60e3676 --- /dev/null +++ b/.gitignore @@ -0,0 +1,117 @@ +## Java + +*.class +*.war +*.ear +hs_err_pid* +keystore.properties +test.jks + +## Robovm +/ios/robovm-build/ + +## GWT +/html/war/ +/html/gwt-unitCache/ +.apt_generated/ +.gwt/ +gwt-unitCache/ +www-test/ +.gwt-tmp/ + +## Android Studio and Intellij and Android in general +/android/libs/armeabi/ +/android/libs/armeabi-v7a/ +/android/libs/arm64-v8a/ +/android/libs/x86/ +/android/libs/x86_64/ +/android/gen/ +.idea/ +*.ipr +*.iws +*.iml +/android/out/ +com_crashlytics_export_strings.xml + +## Eclipse + +.classpath +.project +.metadata/ +/android/bin/ +/core/bin/ +/desktop/bin/ +/html/bin/ +/ios/bin/ +*.tmp +*.bak +*.swp +*~.nib +.settings/ +.loadpath +.externalToolBuilders/ +*.launch + +## NetBeans + +/nbproject/private/ +/android/nbproject/private/ +/core/nbproject/private/ +/desktop/nbproject/private/ +/html/nbproject/private/ +/ios/nbproject/private/ + +/build/ +/android/build/ +/core/build/ +/desktop/build/ +/html/build/ +/ios/build/ + +/nbbuild/ +/android/nbbuild/ +/core/nbbuild/ +/desktop/nbbuild/ +/html/nbbuild/ +/ios/nbbuild/ + +/dist/ +/android/dist/ +/core/dist/ +/desktop/dist/ +/html/dist/ +/ios/dist/ + +/nbdist/ +/android/nbdist/ +/core/nbdist/ +/desktop/nbdist/ +/html/nbdist/ +/ios/nbdist/ + +nbactions.xml +nb-configuration.xml + +## Gradle + +/local.properties +.gradle/ +gradle-app.setting +/build/ +/android/build/ +/core/build/ +/desktop/build/ +/html/build/ +/ios/build/ + +## OS Specific +.DS_Store +Thumbs.db + +## iOS +/ios/xcode/*.xcodeproj/* +!/ios/xcode/*.xcodeproj/xcshareddata +!/ios/xcode/*.xcodeproj/project.pbxproj +/ios/xcode/native/ +/ios/IOSLauncher.app +/ios/IOSLauncher.app.dSYM diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..834f2d2 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1 @@ +version = 2.7.5 \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f5f4b8b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. + diff --git a/Notes.txt b/Notes.txt new file mode 100644 index 0000000..6744b5c --- /dev/null +++ b/Notes.txt @@ -0,0 +1,43 @@ +Creating the application images: +=============================== + +Created the res/ images in Android Studio. New project, right click on Res, New Image Asset, +then using the separate background and foreground PNGs. + +https://developer.android.com/studio/write/image-asset-studio + + +Building it: +============ + +https://developer.android.com/studio/build/building-cmdline + +see options for uploading over usb + +cat keystore.properties +storePassword=secret +keyPassword=secret +keyAlias=Drop +storeFile=test.jks + +./gradlew assemble && ~/bin/gdrive upload -p 1J2Xitf2gzTs44OQz6Ialx2ONB-PAtZih android/build/outputs/apk/release/android-release.apk +android/build/outputs/apk/release/android-release.apk + + +Sounds: +======= + +click: +https://freesound.org/people/JonnyRuss01/sounds/478197/ + +drop: +https://freesound.org/people/TampaJoey/sounds/588502/ + +crash: +https://freesound.org/people/timgormly/sounds/170958/ + +gong: +https://freesound.org/people/josemaria/sounds/55438/ + +triangle +https://freesound.org/people/acclivity/sounds/31189/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..73555be --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +

+ Raised fist holding a one-by-four +

+ +# Tertis + +A [libgdx](https://libgdx.com/) game written, while briefly under the weather, +in [Scala](https://www.scala-lang.org/), the premier programming language +for contemporary mobile and game development. Based loosely on a +[prior thing](https://www.youtube.com/watch?v=YYGulsgO-os). + +The code is horrid and shameful. It contains state and mutation, inconsistencies, +aberrations and general travesties. Nothing is nice here. + +At the time of writing this uses JDK 11, Scala 2.13.8, libgdx 1.11.0 and Android SDK 30. + +## Building for the desktop + +* Run it: + +```shell +./gradlew desktop:run +``` + +* Or package it: + +```shell +./gradlew desktop:dist +``` + +* Achieving greatness: + +```shell +desktop/build/libs/desktop-0.1.jar +``` + +* Which you can run: + +```shell +java -jar desktop/build/libs/desktop-0.1.jar +# or, on a Mac +java -XstartOnFirstThread -jar desktop/build/libs/desktop-0.1.jar +``` + +## Building for Android + +* Get a keystore. + +* Create `keystore.properties`: + +```properties +storePassword= +keyPassword= +keyAlias= +storeFile= +``` + +* Build it: + +```shell +./gradlew assemble +``` + +* Achieve a result: + +```shell +android/build/outputs/apk/release/android-release.apk +``` + +* See also the [Android docs](https://developer.android.com/studio/build/building-cmdline). + +## License + +[Apache License, Version 2.0](LICENSE.md) + +## Credits + +1. assets/click.mp3 - https://freesound.org/people/JonnyRuss01/sounds/478197/ +2. assets/drop.mp3 - https://freesound.org/people/TampaJoey/sounds/588502/ +3. assets/crash.mp3 - https://freesound.org/people/timgormly/sounds/170958/ +4. assets/triangle.mp3 - https://freesound.org/people/acclivity/sounds/31189/ +5. assets/gong.mp3 - https://freesound.org/people/josemaria/sounds/55438/ +6. assets/OpenSans-Regular.ttf - https://fonts.google.com/specimen/Open+Sans +7. assets/tap.png - https://www.iconfinder.com/icons/446301/finger_gesture_hand_interactive_scroll_swipe_tap_icon +8. the raised fist - https://en.wikipedia.org/wiki/Raised_fist#/media/File:Fist_.svg diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..c855c1e --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..13d3392 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,109 @@ +def keystorePropertiesFile = rootProject.file("keystore.properties") +def keystoreProperties = new Properties() +keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + +android { + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile rootProject.file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + } + } + + compileSdkVersion 30 + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = ['src'] + aidl.srcDirs = ['src'] + renderscript.srcDirs = ['src'] + res.srcDirs = ['res'] + assets.srcDirs = ['../assets'] + jniLibs.srcDirs = ['libs'] + } + + } + packagingOptions { + exclude 'META-INF/robovm/ios/robovm.xml' + } + defaultConfig { + applicationId "org.merlin.tertis" + minSdkVersion 26 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + } + } +} + + +// 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. +task copyAndroidNatives { + doFirst { + file("libs/armeabi/").mkdirs() + 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-arm64-v8a.jar")) outputDir = file("libs/arm64-v8a") + if (jar.name.endsWith("natives-armeabi-v7a.jar")) outputDir = file("libs/armeabi-v7a") + if(jar.name.endsWith("natives-armeabi.jar")) outputDir = file("libs/armeabi") + 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.whenTaskAdded { packageTask -> + if (packageTask.name.contains("package")) { + packageTask.dependsOn 'copyAndroidNatives' + } +} + +task run(type: 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_HOME" + } + } else { + path = "$System.env.ANDROID_HOME" + } + + def adb = path + "/platform-tools/adb" + commandLine "$adb", 'shell', 'am', 'start', '-n', 'org.merlin.tertis/org.merlin.tertis.AndroidLauncher' +} + +eclipse.project.name = appName + "-android" diff --git a/android/ic_launcher-playstore.png b/android/ic_launcher-playstore.png new file mode 100644 index 0000000..977a88b Binary files /dev/null and b/android/ic_launcher-playstore.png differ diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro new file mode 100644 index 0000000..d25af4e --- /dev/null +++ b/android/proguard-rules.pro @@ -0,0 +1,44 @@ +# 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 +# http://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 com.badlogic.gdx.backends.android.AndroidFragmentApplication +-dontwarn com.badlogic.gdx.utils.GdxBuild +-dontwarn com.badlogic.gdx.physics.box2d.utils.Box2DBuild +-dontwarn com.badlogic.gdx.jnigen.BuildTarget* +-dontwarn com.badlogic.gdx.graphics.g2d.freetype.FreetypeBuild + +-dontwarn java.lang.ClassValue + +# Required if using Gdx-Controllers extension +-keep class com.badlogic.gdx.controllers.android.AndroidControllers + +# Required if using Box2D 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); +} diff --git a/android/project.properties b/android/project.properties new file mode 100644 index 0000000..551ef21 --- /dev/null +++ b/android/project.properties @@ -0,0 +1,9 @@ +# This file is used by the Eclipse ADT plugin. It is unnecessary for IDEA and Android Studio projects, which +# configure Proguard and the Android target via the build.gradle file. + +# To enable ProGuard to work with Eclipse ADT, uncomment this (available properties: sdk.dir, user.home) +# and ensure proguard.jar in the Android SDK is up to date (or alternately reduce the android target to 23 or lower): +# proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-rules.pro + +# Project target. +target=android-16 diff --git a/android/res/mipmap-anydpi-v26/ic_launcher.xml b/android/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/android/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/android/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/res/mipmap-hdpi/ic_launcher.png b/android/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..ded8622 Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/res/mipmap-hdpi/ic_launcher_background.png b/android/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..9651e74 Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/res/mipmap-hdpi/ic_launcher_foreground.png b/android/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e4f1630 Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/res/mipmap-hdpi/ic_launcher_round.png b/android/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..5da5431 Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/res/mipmap-mdpi/ic_launcher.png b/android/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..0cfdf7d Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/res/mipmap-mdpi/ic_launcher_background.png b/android/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..6789746 Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/res/mipmap-mdpi/ic_launcher_foreground.png b/android/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..efbad27 Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/res/mipmap-mdpi/ic_launcher_round.png b/android/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..16dbfe0 Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/res/mipmap-xhdpi/ic_launcher.png b/android/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a10d555 Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xhdpi/ic_launcher_background.png b/android/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..76d2857 Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4efe548 Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/res/mipmap-xhdpi/ic_launcher_round.png b/android/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e3cf4d2 Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/res/mipmap-xxhdpi/ic_launcher.png b/android/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e58aed0 Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xxhdpi/ic_launcher_background.png b/android/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..48cf3a6 Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..287b388 Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/res/mipmap-xxhdpi/ic_launcher_round.png b/android/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..134fb01 Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/res/mipmap-xxxhdpi/ic_launcher.png b/android/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..c660e31 Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..1b4825a Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ff75c90 Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f5a0348 Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/res/values/color.xml b/android/res/values/color.xml new file mode 100644 index 0000000..933353e --- /dev/null +++ b/android/res/values/color.xml @@ -0,0 +1,4 @@ + + + #FFFFFFFF + diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml new file mode 100644 index 0000000..2989653 --- /dev/null +++ b/android/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Тэятис + + diff --git a/android/src/org/merlin/tertis/AndroidLauncher.java b/android/src/org/merlin/tertis/AndroidLauncher.java new file mode 100644 index 0000000..9b4f97d --- /dev/null +++ b/android/src/org/merlin/tertis/AndroidLauncher.java @@ -0,0 +1,32 @@ +package org.merlin.tertis; + +import android.content.Context; +import android.os.Bundle; +import barsoosayque.libgdxoboe.OboeAudio; +import com.badlogic.gdx.backends.android.AndroidApplication; +import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration; +import com.badlogic.gdx.backends.android.AndroidAudio; + +// This is a giant embarrassment. But getting this to build as scala proves challenging. +// https://github.com/wireapp/gradle-android-scala-plugin seems to be a contemporaryish +// version of this plugin but it was released to sonatype which is now dead, so... + +public class AndroidLauncher extends AndroidApplication { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + AndroidApplicationConfiguration config = new AndroidApplicationConfiguration(); + config.useAccelerometer = true; + config.useCompass = true; + initialize(new Tertis(), config); + } + + // Android audio seemed to have perceptible latency so do this to maybe help. + // https://libgdx.com/wiki/audio/audio#audio-on-android + // https://github.com/barsoosayque/libgdx-oboe/blob/master/docs/Usage.md + @Override + public AndroidAudio createAudio(Context context, AndroidApplicationConfiguration config) { + return new OboeAudio(context.getAssets()); + } + +} diff --git a/assets/OpenSans-Regular.ttf b/assets/OpenSans-Regular.ttf new file mode 100644 index 0000000..3a29f26 Binary files /dev/null and b/assets/OpenSans-Regular.ttf differ diff --git a/assets/arrow-key.png b/assets/arrow-key.png new file mode 100644 index 0000000..7403eca Binary files /dev/null and b/assets/arrow-key.png differ diff --git a/assets/check-off.png b/assets/check-off.png new file mode 100644 index 0000000..69d974a Binary files /dev/null and b/assets/check-off.png differ diff --git a/assets/check-on.png b/assets/check-on.png new file mode 100644 index 0000000..49fc111 Binary files /dev/null and b/assets/check-on.png differ diff --git a/assets/click.mp3 b/assets/click.mp3 new file mode 100644 index 0000000..92b28b7 Binary files /dev/null and b/assets/click.mp3 differ diff --git a/assets/close.png b/assets/close.png new file mode 100644 index 0000000..e8b1721 Binary files /dev/null and b/assets/close.png differ diff --git a/assets/crash.mp3 b/assets/crash.mp3 new file mode 100644 index 0000000..b8edf2e Binary files /dev/null and b/assets/crash.mp3 differ diff --git a/assets/drop.mp3 b/assets/drop.mp3 new file mode 100644 index 0000000..fbee361 Binary files /dev/null and b/assets/drop.mp3 differ diff --git a/assets/help.png b/assets/help.png new file mode 100644 index 0000000..546bc53 Binary files /dev/null and b/assets/help.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..b3eaf70 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/meta-key.png b/assets/meta-key.png new file mode 100644 index 0000000..f826a57 Binary files /dev/null and b/assets/meta-key.png differ diff --git a/assets/music-off.png b/assets/music-off.png new file mode 100644 index 0000000..4b99824 Binary files /dev/null and b/assets/music-off.png differ diff --git a/assets/music-on.png b/assets/music-on.png new file mode 100644 index 0000000..58374a5 Binary files /dev/null and b/assets/music-on.png differ diff --git a/assets/play.png b/assets/play.png new file mode 100644 index 0000000..f47ab39 Binary files /dev/null and b/assets/play.png differ diff --git a/assets/separator.png b/assets/separator.png new file mode 100644 index 0000000..9901f80 Binary files /dev/null and b/assets/separator.png differ diff --git a/assets/settings.png b/assets/settings.png new file mode 100644 index 0000000..ce753ea Binary files /dev/null and b/assets/settings.png differ diff --git a/assets/sound-off.png b/assets/sound-off.png new file mode 100644 index 0000000..29320e2 Binary files /dev/null and b/assets/sound-off.png differ diff --git a/assets/sound-on.png b/assets/sound-on.png new file mode 100644 index 0000000..fbbab36 Binary files /dev/null and b/assets/sound-on.png differ diff --git a/assets/swipe-down.png b/assets/swipe-down.png new file mode 100644 index 0000000..827edec Binary files /dev/null and b/assets/swipe-down.png differ diff --git a/assets/swipe-left.png b/assets/swipe-left.png new file mode 100644 index 0000000..6eb9de2 Binary files /dev/null and b/assets/swipe-left.png differ diff --git a/assets/swipe-right.png b/assets/swipe-right.png new file mode 100644 index 0000000..b1c9e40 Binary files /dev/null and b/assets/swipe-right.png differ diff --git a/assets/swipe-up-down.png b/assets/swipe-up-down.png new file mode 100644 index 0000000..b99cad9 Binary files /dev/null and b/assets/swipe-up-down.png differ diff --git a/assets/tap.png b/assets/tap.png new file mode 100644 index 0000000..1d34a64 Binary files /dev/null and b/assets/tap.png differ diff --git a/assets/trash.png b/assets/trash.png new file mode 100644 index 0000000..30c67fb Binary files /dev/null and b/assets/trash.png differ diff --git a/assets/triangle.mp3 b/assets/triangle.mp3 new file mode 100644 index 0000000..b53a0b8 Binary files /dev/null and b/assets/triangle.mp3 differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..08facab --- /dev/null +++ b/build.gradle @@ -0,0 +1,90 @@ +buildscript { + + + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } + google() + } + dependencies { + classpath 'org.wisepersist:gwt-gradle-plugin:1.1.16' + classpath 'org.gretty:gretty:3.0.7' + classpath 'com.android.tools.build:gradle:7.0.4' + + + } +} + +allprojects { + apply plugin: "eclipse" + + version = '0.1' + ext { + appName = "run" + gdxVersion = '1.11.0' + roboVMVersion = '2.3.15' + box2DLightsVersion = '1.5' + ashleyVersion = '1.7.4' + aiVersion = '1.8.2' + gdxControllersVersion = '2.2.1' + } + + repositories { + mavenLocal() + mavenCentral() + google() + gradlePluginPortal() + maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } + maven { url "https://oss.sonatype.org/content/repositories/releases/" } + maven { url "https://jitpack.io" } + } +} + +project(":desktop") { + apply plugin: "java-library" + + + dependencies { + implementation project(":core") + api "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion" + api "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" + api "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" + implementation "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-desktop" + + } +} + +project(":android") { + apply plugin: "com.android.application" + + configurations { natives } + + dependencies { + implementation project(":core") + implementation "com.github.barsoosayque:libgdxoboe:0.2.4" + api "com.badlogicgames.gdx:gdx-backend-android:$gdxVersion" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86_64" + implementation "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" + natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-armeabi-v7a" + natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-arm64-v8a" + natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86" + natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86_64" + + } +} + +project(":core") { + apply plugin: "java-library" + + + dependencies { + implementation "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" + api "com.badlogicgames.gdx:gdx:$gdxVersion" + + } +} diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..c135bde --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'scala' +} + +sourceCompatibility = JavaVersion.VERSION_1_8 +targetCompatibility = JavaVersion.VERSION_1_8 + +compileScala { + scalaCompileOptions.optimize = true + scalaCompileOptions.additionalParameters = ['-target:jvm-1.8', '-feature', + '-language:postfixOps', '-language:implicitConversions'] + +} + +[compileJava, compileTestJava, compileScala]*.options*.encoding = 'UTF-8' + +//sourceSets.main.java.srcDirs = [ "src/" ] +sourceSets.main.scala.srcDirs = [ "src/" ] + +eclipse.project.name = appName + "-core" + +dependencies { + implementation 'org.scala-lang:scala-library:2.13.8' +} \ No newline at end of file diff --git a/core/src/Tertis.gwt.xml b/core/src/Tertis.gwt.xml new file mode 100644 index 0000000..1c646e0 --- /dev/null +++ b/core/src/Tertis.gwt.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/org/merlin/tertis/Geometry.scala b/core/src/org/merlin/tertis/Geometry.scala new file mode 100644 index 0000000..905fcab --- /dev/null +++ b/core/src/org/merlin/tertis/Geometry.scala @@ -0,0 +1,16 @@ +package org.merlin.tertis + +import com.badlogic.gdx.Gdx + +object Geometry { + val Columns = 10 + val Rows = 20 + // dimension of one block + val Dimension: Int = + (Gdx.graphics.getWidth * 2 / (Columns * 2 + 1)) min (Gdx.graphics.getHeight * 2 / (Rows * 2 + 5)) + val OffsetX: Int = (Gdx.graphics.getWidth - Dimension * Columns) / 2 + val OffsetY: Int = + (Gdx.graphics.getHeight - Dimension * (Rows + 2) + Dimension / 2) / 2 + val Bevel: Int = (Dimension / 50) max 1 + +} diff --git a/core/src/org/merlin/tertis/Prefs.scala b/core/src/org/merlin/tertis/Prefs.scala new file mode 100644 index 0000000..a93d041 --- /dev/null +++ b/core/src/org/merlin/tertis/Prefs.scala @@ -0,0 +1,51 @@ +package org.merlin.tertis + +import com.badlogic.gdx.{Gdx, Preferences} + +class Pref(key: String) { + import Prefs.preferences + + def intValue: Option[Int] = + preferences.contains(key).option(preferences.getInteger(key)) + def longValue: Option[Long] = + preferences.contains(key).option(preferences.getLong(key)) + def booleanValue: Option[Boolean] = + preferences.contains(key).option(preferences.getBoolean(key)) + def set(value: Int): Unit = { + preferences.putInteger(key, value) + preferences.flush() + } + def set(value: Long): Unit = { + preferences.putLong(key, value) + preferences.flush() + } + def set(value: Boolean): Unit = { + preferences.putBoolean(key, value) + preferences.flush() + } + def fold[A](ifTrue: => A, ifFalse: => A): A = + if (booleanValue.isTrue) ifTrue else ifFalse + + def isTrue: Boolean = booleanValue.isTrue +} + +object Prefs { + var preferences: Preferences = _ + + def loadPreferences(): Unit = { + preferences = Gdx.app.getPreferences("tertis") + // preferences.clear() + } + + final val HighScore = new Pref("highScore") + final val HighTime = new Pref("highTime") + final val HighRows = new Pref("highRows") + final val AllTime = new Pref("allTime") + final val Instructed = new Pref("instructed") + final val MuteAudio = new Pref("muteAudio") + final val MuteMusic = new Pref("muteMusic") + final val ZenMode = new Pref("zenMode") + final val HighContrast = new Pref("highContrast") + final val TiltSpeed = new Pref("tiltSpeed") + final val StuffHappens = new Pref("stuffHappens") +} diff --git a/core/src/org/merlin/tertis/Scene.scala b/core/src/org/merlin/tertis/Scene.scala new file mode 100644 index 0000000..ef69b2f --- /dev/null +++ b/core/src/org/merlin/tertis/Scene.scala @@ -0,0 +1,10 @@ +package org.merlin.tertis + +import com.badlogic.gdx.InputAdapter +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch + +abstract class Scene { + def init(): InputAdapter + def update(delta: Float): Option[Scene] + def render(batch: PolygonSpriteBatch): Unit +} diff --git a/core/src/org/merlin/tertis/Tertis.scala b/core/src/org/merlin/tertis/Tertis.scala new file mode 100644 index 0000000..e9da4f3 --- /dev/null +++ b/core/src/org/merlin/tertis/Tertis.scala @@ -0,0 +1,126 @@ +package org.merlin.tertis + +import com.badlogic.gdx.Application.ApplicationType +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.graphics.Pixmap.Format +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import com.badlogic.gdx.graphics.{Pixmap, Texture} +import com.badlogic.gdx.utils.ScreenUtils +import com.badlogic.gdx.{ApplicationAdapter, Gdx, Input} +import org.merlin.tertis.common.Starfield +import org.merlin.tertis.home.Home +import org.merlin.tertis.util.TextureWrapper + +class Tertis extends ApplicationAdapter { + + private var batch: PolygonSpriteBatch = _ + private var scene: Scene = _ + + override def create(): Unit = { + Gdx.input.setCatchKey(Input.Keys.BACK, true) + + Prefs.loadPreferences() + + batch = new PolygonSpriteBatch() + + Tertis.logo = TextureWrapper.load("logo.png") + Tertis.play = TextureWrapper.load("play.png") + + Tertis.separator = TextureWrapper.load("separator.png") + Tertis.tap = TextureWrapper.load("tap.png") + Tertis.swipeDown = TextureWrapper.load("swipe-down.png") + Tertis.swipeLeft = TextureWrapper.load("swipe-left.png") + Tertis.swipeRight = TextureWrapper.load("swipe-right.png") + Tertis.swipeUpDown = TextureWrapper.load("swipe-up-down.png") + + Tertis.soundOff = TextureWrapper.load("sound-off.png") + Tertis.soundOn = TextureWrapper.load("sound-on.png") + Tertis.musicOff = TextureWrapper.load("music-off.png") + Tertis.musicOn = TextureWrapper.load("music-on.png") + Tertis.settings = TextureWrapper.load("settings.png") + Tertis.help = TextureWrapper.load("help.png") + Tertis.close = TextureWrapper.load("close.png") + Tertis.checkOn = TextureWrapper.load("check-on.png") + Tertis.checkOff = TextureWrapper.load("check-off.png") + Tertis.trash = TextureWrapper.load("trash.png") + Tertis.arrowKey = TextureWrapper.load("arrow-key.png") + Tertis.metaKey = + TextureWrapper.load("meta-key.png") // linear filter doesn't help + + Tertis.click = Gdx.audio.newSound(Gdx.files.internal("click.mp3")) + Tertis.drop = Gdx.audio.newSound(Gdx.files.internal("drop.mp3")) + Tertis.crash = Gdx.audio.newSound(Gdx.files.internal("crash.mp3")) + Tertis.end = Gdx.audio.newSound(Gdx.files.internal("triangle.mp3")) + + // TODO: dispose of everything + + Text.loadFonts() + + setScene(new Home) + } + + override def render(): Unit = { + val delta = Gdx.graphics.getDeltaTime + Starfield.update(delta) + scene.update(delta) foreach setScene + ScreenUtils.clear(0, 0, 0, 1) + batch.begin() + Starfield.render(batch) + scene.render(batch) + batch.end() + } + + override def dispose(): Unit = { + batch.dispose() + } + + private def setScene(newScene: Scene): Unit = { + scene = newScene + Gdx.input.setInputProcessor(scene.init()) + } + +} + +object Tertis { + var logo: TextureWrapper = _ + var play: TextureWrapper = _ + + var separator: TextureWrapper = _ + var tap: TextureWrapper = _ + var swipeLeft: TextureWrapper = _ + var swipeRight: TextureWrapper = _ + var swipeDown: TextureWrapper = _ + var swipeUpDown: TextureWrapper = _ + + var soundOff: TextureWrapper = _ + var soundOn: TextureWrapper = _ + var musicOff: TextureWrapper = _ + var musicOn: TextureWrapper = _ + var help: TextureWrapper = _ + var settings: TextureWrapper = _ + var close: TextureWrapper = _ + var checkOn: TextureWrapper = _ + var checkOff: TextureWrapper = _ + var trash: TextureWrapper = _ + var arrowKey: TextureWrapper = _ + var metaKey: TextureWrapper = _ + + var click: Sound = _ + var drop: Sound = _ + var crash: Sound = _ + var end: Sound = _ + + def mobile: Boolean = isMobile(Gdx.app.getType) + + private def isMobile(tpe: ApplicationType) = + tpe == ApplicationType.Android || tpe == ApplicationType.iOS + + val pixture = solidTexture(1f, 1f, 1f, 1f) + + def solidTexture(r: Float, g: Float, b: Float, a: Float): Texture = { + val pixel = new Pixmap(1, 1, Format.RGBA8888) + pixel.setColor(r, g, b, a) + pixel.fill() + new Texture(pixel) + } +} diff --git a/core/src/org/merlin/tertis/Text.scala b/core/src/org/merlin/tertis/Text.scala new file mode 100644 index 0000000..cc39917 --- /dev/null +++ b/core/src/org/merlin/tertis/Text.scala @@ -0,0 +1,65 @@ +package org.merlin.tertis + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator +import com.badlogic.gdx.graphics.g2d.{ + BitmapFont, + GlyphLayout, + PolygonSpriteBatch +} +import org.merlin.tertis.Geometry.Dimension +import org.merlin.tertis.home.Home + +object Text { + def loadFonts(): Unit = { + val generator = new FreeTypeFontGenerator( + Gdx.files.internal("OpenSans-Regular.ttf") + ) + val parameter = new FreeTypeFontGenerator.FreeTypeFontParameter + parameter.characters = FreeTypeFontGenerator.DEFAULT_CHARS + CharExtras + parameter.size = Dimension + bigFont = generator.generateFont(parameter) + parameter.size = Dimension * 3 / 4 + mediumFont = generator.generateFont(parameter) + parameter.size = Dimension * 9 / 16 + smallFont = generator.generateFont(parameter) + parameter.size = Dimension * 3 / 8 + tinyFont = generator.generateFont(parameter) + generator.dispose() + } + + private val CharExtras = Home.Title + + var bigFont: BitmapFont = _ + var mediumFont: BitmapFont = _ + var smallFont: BitmapFont = _ + var tinyFont: BitmapFont = _ + + def draw( + batch: PolygonSpriteBatch, + font: BitmapFont, + color: Color, + text: String, + y: Float, + x: Float = 0, + width: Float = Gdx.graphics.getWidth + ): Unit = { + font.setColor(color) + font.draw(batch, text, x, y, width, 1, false) + } + + def draw( + batch: PolygonSpriteBatch, + font: BitmapFont, + color: Color, + text: String, + position: GlyphLayout => (Float, Float) + ): Unit = { + font.setColor(color) + val layout = new GlyphLayout(font, text) + val (x, y) = position(layout) + font.draw(batch, layout, x, y) + } + +} diff --git a/core/src/org/merlin/tertis/common/Frame.scala b/core/src/org/merlin/tertis/common/Frame.scala new file mode 100644 index 0000000..8a50b34 --- /dev/null +++ b/core/src/org/merlin/tertis/common/Frame.scala @@ -0,0 +1,101 @@ +package org.merlin.tertis.common + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Gdx.graphics +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import com.badlogic.gdx.math.Rectangle +import org.merlin.tertis.Geometry._ +import org.merlin.tertis.Tertis.pixture + +object Frame { + // Not really alpha, but the 0f..1f size of the frame + var alpha: Float = 0f + var targetAlpha: Float = 0f + var frame: Rectangle = new Rectangle() + + def update(delta: Float): Unit = { + if (alpha < targetAlpha) { + alpha = (alpha + delta / FadeSpeedSeconds) min 1f + } else if (alpha > targetAlpha) { + alpha = (alpha - delta / FadeSpeedSeconds) max 0f + } + + val offsetY = (OffsetY * alpha).toInt + val offsetX = (OffsetX * alpha).toInt + val width = + Gdx.graphics.getWidth * (1f - alpha) + Columns * Dimension * alpha + val height = + Gdx.graphics.getHeight * (1f - alpha) + Rows * Dimension * alpha + frame.set(offsetX, offsetY, width, height) + } + + def render(batch: PolygonSpriteBatch): Unit = { + batch.setColor(BlackColour) + batch.draw( + pixture, + 0, + 0, + graphics.getWidth, + frame.y + ) + batch.draw( + pixture, + 0, + frame.y + frame.height, + graphics.getWidth, + graphics.getHeight - frame.y - frame.height + ) + batch.draw( + pixture, + 0, + 0, + frame.x, + graphics.getHeight + ) + batch.draw( + pixture, + frame.x + frame.width, + 0, + graphics.getWidth - frame.x - frame.width, + graphics.getHeight + ) + Starfield.renderOnFrame(batch) + GreyColour.a = alpha + batch.setColor(GreyColour) + batch.draw( + pixture, + frame.x, + frame.y - 1, + frame.width + 1, + 1 + ) + batch.draw( + pixture, + frame.x + frame.width, + frame.y, + 1, + frame.height + 1 + ) + batch.draw( + pixture, + frame.x - 1, + frame.y + frame.height, + frame.width + 1, + 1 + ) + batch.draw( + pixture, + frame.x - 1, + frame.y - 1, + 1, + frame.height + 1 + ) + + } + + private val BlackColour = new Color(0, 0, 0, 1) + private val GreyColour = new Color(.5f, .5f, .5f, 1) + + val FadeSpeedSeconds = .5f +} diff --git a/core/src/org/merlin/tertis/common/Star.scala b/core/src/org/merlin/tertis/common/Star.scala new file mode 100644 index 0000000..045c8ae --- /dev/null +++ b/core/src/org/merlin/tertis/common/Star.scala @@ -0,0 +1,73 @@ +package org.merlin.tertis.common + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Gdx.graphics +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import com.badlogic.gdx.math.{MathUtils, Quaternion, Vector3} +import org.merlin.tertis.Tertis + +case class Star( + location: Vector3 +) { + import Star._ + var x, y, z: Float = _ + + def update(translation: Vector3, rotation: Quaternion): Boolean = { + location.add(translation) + val rotated = rotation.transform(location.cpy) + z = rotated.z + x = (rotated.x * ViewerDistance / z + w2).floor + y = (rotated.y * ViewerDistance / z + h2).floor + (z > ViewerDistance) && (x >= 0) && (y >= 0) && (x < Gdx.graphics.getWidth) && (y < Gdx.graphics.getHeight) + } + + def draw( + batch: PolygonSpriteBatch, + alpha: Float, + pred: (Float, Float) => Boolean + ): Unit = { + if (pred(x, y)) { + val starAlpha = (FarDistance - z) / FarDistance * alpha * alpha + if (size <= 1) { + batch.setColor(.7f, .7f, .7f, starAlpha) + batch.draw(Tertis.pixture, x, y, 1, 1) + } else { + batch.setColor(.7f, .7f, .7f, starAlpha) + batch.draw(Tertis.pixture, x - 1, y, 3, 1) + batch.draw(Tertis.pixture, x, y - 1, 1, 3) + } + } + } +} + +object Star { + private val size = (graphics.getWidth / 500f).floor + private val w2 = Gdx.graphics.getWidth * .5f + private val h2 = Gdx.graphics.getHeight * .5f + private val ViewerDistance = h2 * 2 + val FarDistance = 10 * ViewerDistance + + val qIdentity = new Quaternion() + + // new initial star on any z index + def newStar: Star = newStar(qIdentity, ViewerDistance, FarDistance) + + // replacement star at far distance + def newStar( + rotation: Quaternion, + z0: Float = FarDistance, + z1: Float = FarDistance + ): Star = { + // So .. this is inadequate. If you are rotating and stars fall off one side, we replace them uniformly + // across the viewport which means the other side will have a deficit of stars. Really we want to replace + // any stars that were rotated off screen with new stars created within the full depth of the view frustum + // that is now visible, so near stars will rotate on screen. But I'm not doing that now. + + val z = MathUtils.random(z0, z1) + val x = w2 * z / ViewerDistance + val y = h2 * z / ViewerDistance + val loc = new Vector3(MathUtils.random(-x, x), MathUtils.random(-y, y), z) + rotation.transform(loc) + new Star(loc) + } +} diff --git a/core/src/org/merlin/tertis/common/Starfield.scala b/core/src/org/merlin/tertis/common/Starfield.scala new file mode 100644 index 0000000..93abaaa --- /dev/null +++ b/core/src/org/merlin/tertis/common/Starfield.scala @@ -0,0 +1,75 @@ +package org.merlin.tertis +package common + +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import com.badlogic.gdx.math.{Quaternion, Vector3} + +import scala.collection.mutable +import scala.collection.mutable.ListBuffer + +object Starfield { + + val NumStars = 256 + val FadeInSeconds = 5f + + var alpha = 1f + + val stars: ListBuffer[Star] = mutable.ListBuffer + .fill(NumStars)(Star.newStar) + .sortBy(star => -star.location.z) + + private val rotation = new Quaternion() + private val translation = new Vector3() + + var r = 0f + def update(delta: Float): Unit = { + r = r + delta + + if (Tertis.mobile) { + // So... I want this to match the device orientation, but I just can't. + // Even with the LPQF the display is super jittery, and then the axes are + // wrong; the rotation matrix isn't how I expect it to be and so most + // phone rotations cause the starfield to rotate unexpectedly... +// private val rawRotation = new Matrix3() +// private val lowPassFilter = new LowPassQuaternionFilter(60) +// Gdx.input.getRotationMatrix(rawRotation.getValues) +// rawRotation.transpose() +// lowPassFilter.add(rawRotation) +// rotation.set(lowPassFilter.value) + rotation.set(Vector3.X, r / 3) + } else { + rotation.set(Vector3.X, r / 3) + } + val inverse = new Quaternion(rotation).conjugate() + + alpha = (alpha + delta / FadeInSeconds) min 1f + + translation.set(0, 0, -delta * 300) + rotation.transform(translation) + stars.filterInPlace( + _.update(translation, inverse) + ) // filterInPlace + + // I don't maintain the sort order of the list but it should remain relatively ordered + while (stars.size < NumStars) + stars.prepend(Star.newStar(rotation)) + } + + def render(batch: PolygonSpriteBatch): Unit = { + renderImpl(batch, within = true) + } + + def renderOnFrame(batch: PolygonSpriteBatch): Unit = { + renderImpl(batch, within = false) + } + + def renderImpl(batch: PolygonSpriteBatch, within: Boolean): Unit = { + val a = within.fold(1f - Frame.alpha / 2, 1f) * alpha + if (a != 0f) { + stars.foreach( + _.draw(batch, a, (x, y) => Frame.frame.contains(x, y) == within) + ) + } + } + +} diff --git a/core/src/org/merlin/tertis/game/Block.scala b/core/src/org/merlin/tertis/game/Block.scala new file mode 100644 index 0000000..eb8a1f9 --- /dev/null +++ b/core/src/org/merlin/tertis/game/Block.scala @@ -0,0 +1,170 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.MathUtils + +trait Test extends ((Int, Int) => Boolean) + +final class Block( + color: Color, + hcColor: Color, + bits: Array[String], + symmetric: Boolean = false +) { + import Block.Solid + + def getColor: Color = Prefs.HighContrast.fold(hcColor, color) + + assert(bits.forall(_.length == bits.length), bits.mkString(",")) + + val size: Int = bits.length + + val vOffset = bits.reverse.takeWhile(!_.contains(Solid)).length + val vWidth = bits.count(_.contains(Solid)) + + def forall(rotation: Int, f: (Int, Int) => Boolean): Boolean = { + var result = true + for (i <- 0 until size if result) { + for (j <- 0 until size if result) { + val (x, y) = translate(rotation, i, j) + result = bits(x)(y) != Solid || f(i, j) + } + } + result + } + + def exists(rotation: Int, f: (Int, Int) => Boolean): Boolean = + !forall(rotation, (x, y) => !f(x, y)) + + def foreach(rotation: Int, f: (Int, Int) => Unit): Unit = + forall(rotation, (i, j) => f(i, j) as true) + + def eachSquare[A]( + rotation: Int, + f: (Int, Int, (Int, Int) => Boolean) => A + ): Unit = { + for (i <- 0 until size) { + for (j <- 0 until size) { + val (x, y) = translate(rotation, i, j) + if (bits(x)(y) == Solid) { + f(i, j, (di, dj) => test(rotation, i + di, j + dj)) + } + } + } + + } + + def test(rotation: Int, i: Int, j: Int): Boolean = { + val (x, y) = translate(rotation, i, j) + (x >= 0) && (x < size) && (y >= 0) && (y < size) && bits(x)(y) == Solid + } + + private def translate(rotation: Int, i: Int, j: Int): (Int, Int) = + (rotation % (symmetric.fold(2, 4))) match { + case 0 => (size - 1 - j, i) + case 1 => (size - 1 - i, size - 1 - j) + case 2 => (j, size - 1 - i) + case 3 => (i, j) + case _ => throw new IllegalArgumentException(s"Rotation $rotation") + } +} + +// TODO: support textures for colour blind accessibility... +object Block { + final val Solid = '#' + + def random: Block = blocks(randomNumber(blocks.length)) + + // I question the randomness of MathUtils.random for 7 + private def randomNumber(n: Int): Int = { + if (n == 7) { + var rnd: Int = 0 + do rnd = MathUtils.random.nextInt >>> 29 while (rnd >= n) + rnd + } else { + MathUtils.random(n - 1) + } + } + + // https://observablehq.com/@shan/oklab-color-wheel + // oklab colour space for perceptual calmness + // 6/2/4.51/0.1/255 + // hc: 6/2/3.83/0.14/255 + // hc: 6/2/3.83/0.29/255 + val blocks: List[Block] = List( + new Block( + rgb(246, 246, 246), + rgb(246, 246, 246), + """ + |## + |## + |""".toBlock + ), + new Block( + rgb(235, 245, 255), + rgb(221, 223, 255), + """ + |.... + |.... + |#### + |.... + |""".toBlock, + true + ), + new Block( + rgb(127, 255, 255), + rgb(0, 255, 255), + """ + |... + |..# + |### + |""".toBlock + ), + new Block( + rgb(165, 255, 221), + rgb(0, 255, 181), + """ + |... + |### + |..# + |""".toBlock + ), + new Block( + rgb(255, 255, 139), + rgb(255, 253, 0), + """ + |##. + |.## + |... + |""".toBlock, + true + ), + new Block( + rgb(255, 222, 183), + rgb(255, 166, 60), + """ + |.## + |##. + |... + |""".toBlock, + true + ), + new Block( + rgb(255, 218, 255), + rgb(255, 160, 255), + """ + |... + |### + |.#. + |""".toBlock + ) + ) + + private def rgb(r: Int, g: Int, b: Int) = + new Color(r / 255f, g / 255f, b / 255f, 1f) + + implicit class StringOps(val s: String) extends AnyVal { + def toBlock: Array[String] = s.stripMargin.trim.split('\n') + } +} diff --git a/core/src/org/merlin/tertis/game/BlockRenderer.scala b/core/src/org/merlin/tertis/game/BlockRenderer.scala new file mode 100644 index 0000000..4c646ef --- /dev/null +++ b/core/src/org/merlin/tertis/game/BlockRenderer.scala @@ -0,0 +1,93 @@ +package org.merlin.tertis.game + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.{ + PolygonRegion, + PolygonSprite, + PolygonSpriteBatch, + TextureRegion +} +import org.merlin.tertis.Tertis + +object BlockRenderer { + + def render( + batch: PolygonSpriteBatch, + color: Color, + x: Int, + y: Int, + w: Int, + h: Int, + v: Int, // bevel + test: (Int, Int) => Boolean + ): Unit = { + batch.setColor(color) + batch.draw(Tertis.pixture, x, y, w, h) + Shadow.a = color.a * .1f + Highlight.a = color.a * .3f + Highlight + // This could be done much more efficiently without the PolygonRegion and PolygonSprite, + // by just computing the packed vertex information and rendering polygons directly + def drawPoly(tint: Color, vertices: Float*): Unit = { + assert(vertices.length == 8 || vertices.length == 6) + val polygonRegion = new PolygonRegion( + new TextureRegion(Tertis.pixture), + vertices.toArray, + if (vertices.length == 8) // counterclockwise triangles + Array[Short](0, 1, 2, 0, 2, 3) + else Array[Short](0, 1, 2) + ) + val poly = new PolygonSprite(polygonRegion) + poly.setPosition(x, y) + poly.setColor(tint) + poly.draw(batch) + } + // I just overdraw twice for the stronger shade + if (!test(0, -1)) { // nothing below + val l = if (test(-1, 0)) 0 else v + val r = if (test(1, 0)) 0 else v + drawPoly(Shadow, 0, 0, w, 0, w - r * 2, v * 2, l * 2, v * 2) + drawPoly(Shadow, 0, 0, w, 0, w - r, v, l, v) + } else if (test(-1, 0) && !test(-1, -1)) { + drawPoly(Shadow, 0, 0, v * 2, v * 2, 0, v * 2) + drawPoly(Shadow, 0, 0, v, v, 0, v) + } else if (test(1, 0) && !test(1, -1)) { + drawPoly(Shadow, w - v * 2, 0, w, 0, w, v * 2, w - v * 2, v * 2) + drawPoly(Shadow, w - v, 0, w, 0, w, v, w - v, v) + } + if (!test(1, 0)) { // nothing to the right + val b = if (test(0, -1)) 0 else v + val t = if (test(0, 1)) 0 else v + drawPoly(Shadow, w - v * 2, b * 2, w, 0, w, h, w - v * 2, h - t * 2) + drawPoly(Shadow, w - v, b, w, 0, w, h, w - v, h - t) + } else if (test(0, 1) && !test(1, 1)) { + drawPoly(Shadow, w - v * 2, h - v * 2, w, h, w - v * 2, h) + drawPoly(Shadow, w - v, h - v, w, h, w - v, h) + } + if (!test(0, 1)) { // nothing above + val l = if (test(-1, 0)) 0 else v + val r = if (test(1, 0)) 0 else v + drawPoly(Highlight, l * 2, h - v * 2, w - r * 2, h - v * 2, w, h, 0, h) + drawPoly(Highlight, l, h - v, w - r, h - v, w, h, 0, h) + } else if (test(1, 0) && !test(1, 1)) { + drawPoly(Highlight, w - v * 2, h - v * 2, w, h - v * 2, w, h) + drawPoly(Highlight, w - v, h - v, w, h - v, w, h) + } else if (test(-1, 0) && !test(-1, 1)) { + drawPoly(Highlight, 0, h - v * 2, v * 2, h - v * 2, v * 2, h, 0, h) + drawPoly(Highlight, 0, h - v, v, h - v, v, h, 0, h) + } + if (!test(-1, 0)) { // nothing to the left + val b = if (test(0, -1)) 0 else v + val t = if (test(0, 1)) 0 else v + drawPoly(Highlight, 0, 0, v * 2, b * 2, v * 2, h - t * 2, 0, h) + drawPoly(Highlight, 0, 0, v, b, v, h - t, 0, h) + } else if (test(0, -1) && !test(-1, -1)) { + drawPoly(Highlight, 0, 0, v * 2, 0, v * 2, v * 2) + drawPoly(Highlight, 0, 0, v, 0, v, v) + } + } + + private val Shadow = new Color(0, 0, 0, .1f) + private val Highlight = new Color(1, 1, 1, .3f) + +} diff --git a/core/src/org/merlin/tertis/game/Board.scala b/core/src/org/merlin/tertis/game/Board.scala new file mode 100644 index 0000000..885491f --- /dev/null +++ b/core/src/org/merlin/tertis/game/Board.scala @@ -0,0 +1,200 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Geometry._ +import org.merlin.tertis.{Prefs, Tertis} + +import scala.collection.mutable + +/** Identifier allows us to identify all the pixels of a single block. They may get split if + * a row is dropped. + */ +final case class Region( + color: Color, + identifier: Int, + dead: Boolean +) + +object Region { + def apply(color: Color, dead: Boolean = false): Region = { + generator += 1 + Region(color, generator, dead) + } + + private[this] var generator: Int = 0 +} + +class Board(game: Game) { + import Board._ + + // null? Yes! + val board = Array.fill[Region](BoardRows * Columns)(null) + val dropRows = mutable.Set.empty[Int] + var redAlpha: Float = 0f + var redVelocity: Float = 0f + var redShift: Float = 0f // 1f per row shifted + // if a row drop has two pieces to it, the length of the first run + var firstChunk: Int = 0 + var ending: Boolean = false + +// val reg = Region(Color.ROYAL) +// for (j <- 0 until 20 * Columns) if (j % Columns > 0) board.update(j, reg) + + def animating: Boolean = dropRows.nonEmpty + + def ended: Boolean = redShift >= BoardRows + + def update(delta: Float): Unit = { + if (ending) { + redAlpha = redAlpha.alphaUp(delta, RedFadeSeconds) + redVelocity = redVelocity + delta / RedShiftAccelerationSeconds + redShift = (redShift + redVelocity) min BoardRows + } else if (animating) { + if (redAlpha < 1f) { + redAlpha = redAlpha.alphaUp(delta, RedFadeSeconds) + } else { + redVelocity = redVelocity + delta / RedShiftAccelerationSeconds + val xShift = redShift + redShift = (redShift + redVelocity) min dropRows.size + if (redShift >= dropRows.size) { + var drop = 0 + var mass = 0 + for (j <- 0 until BoardRows) { + while (dropRows.contains(j + drop)) drop = drop + 1 + for (i <- 0 until Columns) { + val region = get(i, j + drop) + set(i, j, region) + if (drop > 0 && (region ne null)) mass = mass + 1 + } + } + if (!Prefs.MuteAudio.isTrue && mass > 0) + Tertis.crash.play( + (mass * drop / 400f) min 1f + ) // max mass is ~140, max drop is 4 + dropRows.clear() + redAlpha = 0f + redShift = 0f + redVelocity = 0f + game.score.cleared(drop, Columns * drop + mass) + } else if (xShift < firstChunk && redShift >= firstChunk) { + if (!Prefs.MuteAudio.isTrue) + Tertis.crash.play(15f / 400f) // arbitrary.. + } + } + } + } + + def draw(batch: PolygonSpriteBatch): Unit = { + var dropped = 0 + for (j <- 0 until BoardRows) { + if (dropRows.contains(j)) dropped = dropped + 1 + val yShift = ending.fold( + (redShift * Dimension).toInt, + (redShift * Dimension).toInt min (dropped * Dimension) + ) + for (i <- 0 until Columns) { + val region = board(j * Columns + i) + if (region ne null) { + Red.a = 1f - redAlpha // fade through red to invisible + val color = + if (ending || region.dead) + region.color.cpy.lerp(Red, redAlpha * redAlpha) + else region.color + BlockRenderer.render( + batch, + color, + OffsetX + i * Dimension, + OffsetY + j * Dimension - yShift, + Dimension, + Dimension, + Bevel, + (di, dj) => get(i + di, j + dj) eq region + ) + } + } + } + } + + private def set(i: Int, j: Int, region: Region /* | null */ ): Unit = + if ((i >= 0) && (i < Columns) && (j >= 0) && (j < BoardRows)) + board.update(j * Columns + i, region) + + private def get(i: Int, j: Int): Region /* | null */ = + if ((i >= 0) && (i < Columns) && (j >= 0) && (j < BoardRows)) + board(j * Columns + i) + else null + + def test(i: Int, j: Int): Boolean = get(i, j) ne null + + def drop( + block: Block, + rotation: Int, + column: Int, + row: Int + ): Unit = { + val region = Region(block.getColor) + block.foreach( + rotation, + (i, j) => set(column + i, row + j, region) + ) + // map regions that are broken by a drop into new regions so they render as separate pieces + val regionMap = mutable.Map.empty[Int, Region] + var dropping = false // are we it a group of drops + var firstSpan = true + for (j <- 0 until BoardRows) { + val drop = rowIsFull(j) + if (drop) { + dropRows.add(j) + } + if (dropping != drop) { + regionMap.clear() + if (dropping && firstSpan) { + // have I just ended the first span of drops + firstSpan = false + firstChunk = dropRows.size + } + } + if (drop || dropping != drop) { + for (i <- 0 until Columns) { + val region = get(i, j) + if (region ne null) + regionMap.getOrElseUpdate( + region.identifier, + Region(region.color, drop) + ) + } + dropping = drop + } + if (dropRows.nonEmpty) { + for (i <- 0 until Columns) { + val region = get(i, j) + val mapped = + if (region eq null) region + else regionMap.getOrElse(region.identifier, region) + set(i, j, mapped) + } + } + } + } + + def rowIsFull(row: Int): Boolean = + (row >= 0) && (row < BoardRows) && (0 until Columns).forall(column => + board(row * Columns + column) ne null + ) + + def reset(): Unit = + for (j <- 0 until BoardRows) + for (i <- 0 until Columns) + set(i, j, null) +} + +object Board { + // 84 extra for some room off the top + val BoardRows = Rows + 4 + + private val Red = new Color(1, 0, 0, 1f) // nb: mutates + val RedFadeSeconds = .5f + val RedShiftAccelerationSeconds = .5f +} diff --git a/core/src/org/merlin/tertis/game/Change.scala b/core/src/org/merlin/tertis/game/Change.scala new file mode 100644 index 0000000..39ea5d8 --- /dev/null +++ b/core/src/org/merlin/tertis/game/Change.scala @@ -0,0 +1,20 @@ +package org.merlin.tertis.game + +/** @param value -1 or -1 + * @param timestamp start time + * + * A keypress is still valid within a few milliseconds of creation, even if no longer + * active. + */ +case class Change( + value: Int, + timestamp: Long, + auto: Boolean = false +) + +case object Change { + def down: Change = Change(-1, System.currentTimeMillis) + def up: Change = Change(1, System.currentTimeMillis) + def autoDown: Change = down.copy(auto = true) + def autoUp: Change = up.copy(auto = true) +} diff --git a/core/src/org/merlin/tertis/game/Game.scala b/core/src/org/merlin/tertis/game/Game.scala new file mode 100644 index 0000000..3fc788f --- /dev/null +++ b/core/src/org/merlin/tertis/game/Game.scala @@ -0,0 +1,66 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Scene +import org.merlin.tertis.common.{Frame, Starfield} +import org.merlin.tertis.home.Home + +class Game extends Scene { + import Game._ + + var state: State = PlayingState + + val zenMode: Boolean = Prefs.ZenMode.isTrue + var stuffHappens: Boolean = Prefs.StuffHappens.isTrue + + val board: Board = new Board(this) + val nextUp: NextUp = new NextUp + var player: Player = new Player(this) + val score: Score = new Score + var fast: Boolean = false + var gravity: Boolean = false + // TODO: a queue of changes instead? How to do so... + var shift: Option[Change] = None + var autoShift: Option[Change] = None + var rotate: Option[Change] = None + var clickPlayed: Boolean = false + + override def init(): GameControl = { + state = PlayingState + Frame.targetAlpha = 1f + new GameControl(this) + } + + override def update(delta: Float): Option[Scene] = { + Starfield.update(delta) + Frame.update(delta) + player.update(delta) + nextUp.update(delta) + score.update(delta) + board.update(delta) + if (player.blockOpt.isEmpty && !board.animating) { + player.next(nextUp.shift()) + } + PartialFunction.condOpt(state) { + case QuitState => Home(this) + case LostState => new Over(board, score) + } + } + + override def render(batch: PolygonSpriteBatch): Unit = { + Starfield.render(batch) + board.draw(batch) + player.draw(batch) + Frame.render(batch) + score.draw(batch) + nextUp.render(batch) + } +} + +object Game { + sealed trait State + case object PlayingState extends State + case object LostState extends State + case object QuitState extends State +} diff --git a/core/src/org/merlin/tertis/game/GameControl.scala b/core/src/org/merlin/tertis/game/GameControl.scala new file mode 100644 index 0000000..54d04bf --- /dev/null +++ b/core/src/org/merlin/tertis/game/GameControl.scala @@ -0,0 +1,130 @@ +package org.merlin.tertis.game + +import com.badlogic.gdx.Input.Keys +import com.badlogic.gdx.{Gdx, InputAdapter} +import org.merlin.tertis.BooleanOps + +import scala.collection.mutable + +class GameControl(game: Game) extends InputAdapter { + import GameControl._ + + private val down = mutable.Map.empty[Int, (Int, Int)] + + // this doesn't actually use circle like the help suggests because it is not good + override def touchDown( + screenX: Int, + screenY: Int, + pointer: Int, + button: Int + ): Boolean = { + down.put(pointer, (screenX, screenY)) + true + } + + override def touchUp( + screenX: Int, + screenY: Int, + pointer: Int, + button: Int + ): Boolean = { + down.remove(pointer) foreach { case (oldX, oldY) => + val third = oldX * 3 / Gdx.graphics.getWidth + + val swipe = + (oldX - screenX) * (oldX - screenX) + (oldY - screenY) * (oldY - screenY) > SwipeDistance * SwipeDistance + if (!swipe) { + if (third < 1) { + game.shift = Some(Change.down) + } else if (third > 1) { + game.shift = Some(Change.up) + } + } else { // swipe + if ( + (screenX - oldX < 0) && ((oldY - screenY).abs < (oldX - screenX).abs) + ) { // swipe left + game.shift = Some(Change.down.copy(auto = true)) + + } else if ( + (screenX - oldX > 0) && ((oldY - screenY).abs < (oldX - screenX).abs) + ) { // swipe right + game.shift = Some(Change.up.copy(auto = true)) + + } else if ( + (screenY - oldY < 0) && ((oldY - screenY).abs > (oldX - screenX).abs) + ) { // swipe up + if (third > 1) { + game.rotate = Some(Change.down) + } else if (third < 1) { + game.rotate = Some(Change.up) + } + + } else if ( + (screenY - oldY > 0) && ((oldY - screenY).abs > (oldX - screenX).abs) + ) { // swipe down + if (third == 1) { + game.gravity = true + } else if (third > 1) { + game.rotate = Some(Change.up) + } else if (third < 1) { + game.rotate = Some(Change.down) + } + } + } + } + true + } + + override def keyDown(keycode: Int): Boolean = { + if (keycode == Left) { + game.shift = Some(Change.down) + game.autoShift = Some(Change.autoDown) + } else if (keycode == Right) { + game.shift = Some(Change.up) + game.autoShift = Some(Change.autoUp) + } else if (keycode == Keys.HOME) { + game.shift = Some(Change.autoDown) + } else if (keycode == Keys.END) { + game.shift = Some(Change.autoUp) + } else if (keycode == Rotate) { + game.rotate = Some(Change.down) + } else if (keycode == Drop) { + game.gravity = true + } else if (Speeds.contains(keycode)) { + game.fast = true + } else if (keycode == Keys.ESCAPE || keycode == Keys.BACK) { + game.state = Game.QuitState + } + true + } + + override def keyUp(keycode: Int): Boolean = { + if (keycode == Left) { + game.shift = game.shift.filterNot(_.value < 0) + game.autoShift = game.autoShift.filterNot(_.value < 0) + } else if (keycode == Right) { + game.shift = game.shift.filterNot(_.value > 0) + game.autoShift = game.autoShift.filterNot(_.value > 0) + } else if (Speeds.contains(keycode)) { + game.fast = false + } + true + } + +} + +object GameControl { + private val Left = Keys.LEFT + private val Right = Keys.RIGHT + private val Rotate = Keys.UP + private val Drop = Keys.DOWN + private val Speeds = Set( + Keys.CONTROL_LEFT, + Keys.CONTROL_RIGHT, + Keys.SHIFT_LEFT, + Keys.SHIFT_RIGHT, + Keys.ALT_LEFT, + Keys.ALT_RIGHT + ) + private val SwipeDistance = Gdx.graphics.getHeight / 32 +} diff --git a/core/src/org/merlin/tertis/game/NextUp.scala b/core/src/org/merlin/tertis/game/NextUp.scala new file mode 100644 index 0000000..46b4b54 --- /dev/null +++ b/core/src/org/merlin/tertis/game/NextUp.scala @@ -0,0 +1,64 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Geometry._ + +class NextUp { + var previous: Block = Block.random + private var previousAlpha = 0f + + private var next: Block = Block.random + private var nextAlpha = 0f + + def shift(): Block = { + previous = next + previousAlpha = nextAlpha + next = Block.random + nextAlpha = 0f + previous + } + + def update(delta: Float): Unit = { + previousAlpha = (previousAlpha - delta / FadeOutSeconds) max 0f + nextAlpha = (nextAlpha + delta / FadeInSeconds) min 1f + } + + def render(batch: PolygonSpriteBatch): Unit = { + draw(batch, previous, previousAlpha, -.5f) + draw(batch, next, nextAlpha, 1f) + } + + private def draw( + batch: PolygonSpriteBatch, + block: Block, + alpha: Float, + y: Float + ): Unit = { + val Small = Dimension / 2 + // It is known that the shapes have width 2..4 and height 1..2 + val nextX = + OffsetX + Columns * Dimension - (4 + block.size) * Small / 2 + val nextY = + OffsetY + Rows * Dimension + (1 - block.vOffset) * Small + (y * (1f - alpha) * Dimension).toInt + // + (2 - next.vWidth) * Small / 2 + val nextColor = block.getColor ⍺⍺ alpha ⍺ 0.5f + block.eachSquare( + 0, + (i, j, test) => + BlockRenderer.render( + batch, + nextColor, + nextX + i * Small, + nextY + j * Small, + Small, + Small, + Bevel, + test + ) + ) + } + + val FadeOutSeconds = 0.5f + val FadeInSeconds = 0.5f +} diff --git a/core/src/org/merlin/tertis/game/Over.scala b/core/src/org/merlin/tertis/game/Over.scala new file mode 100644 index 0000000..fa94177 --- /dev/null +++ b/core/src/org/merlin/tertis/game/Over.scala @@ -0,0 +1,107 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Scene +import org.merlin.tertis.common.{Frame, Starfield} +import org.merlin.tertis.home.Home + +import scala.concurrent.duration.{DurationInt, DurationLong} + +class Over(board: Board, score: Score) extends Scene { + import Over._ + + var time: Float = 0f + var alpha: Float = 0f + + var done: Boolean = false + + def home(): Unit = { + if (time >= .5f) done = true + } + + override def init(): OverControl = { + board.ending = true + Frame.targetAlpha = 0f + new OverControl(this) + } + + override def update(delta: Float): Option[Scene] = { + time = time + delta + if (done) + alpha = alpha.alphaDown(delta, OverFadeOutSeconds) + else if (time >= OverDelaySeconds) + alpha = alpha.alphaUp(delta, OverFadeInSeconds) + Frame.update(delta) + board.update(delta) + (done && alpha == 0f).option(new Home) + } + + override def render(batch: PolygonSpriteBatch): Unit = { + Starfield.render(batch) + board.draw(batch) + + val color = Color.WHITE ⍺⍺ alpha + Text.mediumFont.setColor(color) + Text.smallFont.setColor(color) + Text.tinyFont.setColor(color) + + // want springs, will pay + val content = + Text.mediumFont.getLineHeight + Text.smallFont.getLineHeight + 5 * Text.tinyFont.getLineHeight + val margin = + (Gdx.graphics.getHeight - content) / 4 + + val textY = Gdx.graphics.getHeight - margin + Text.draw( + batch, + Text.mediumFont, + color, + "Game Over", + textY + ) + + val scoreY = textY - Text.mediumFont.getLineHeight - margin + Text.draw( + batch, + Text.smallFont, + color, + f"${score.highScore.fold("High Score", "Score")}%s: ${score.score}%,d", + scoreY + ) + + val statsY = + scoreY - Text.smallFont.getLineHeight - Text.tinyFont.getLineHeight + Text.draw( + batch, + Text.tinyFont, + color, + f"""${score.count}%,d blocks + |${score.rows}%,d row${(score.rows != 1).fold("s", "")}%s + |${score.time.toInt.seconds.toHumanString}%s + |""".stripMargin, + statsY + ) + + Prefs.AllTime.longValue foreach { allTime => + Text.draw( + batch, + Text.tinyFont, + color, + s"All Time: ${allTime.seconds.toHumanString}", + margin + Text.tinyFont.getLineHeight + ) + } + + Frame.render(batch) + + } +} + +object Over { + val OverDelaySeconds = 0.2f + val OverFadeInSeconds = 0.5f + val OverFadeOutSeconds = 0.3f +} diff --git a/core/src/org/merlin/tertis/game/OverControl.scala b/core/src/org/merlin/tertis/game/OverControl.scala new file mode 100644 index 0000000..41dd4a2 --- /dev/null +++ b/core/src/org/merlin/tertis/game/OverControl.scala @@ -0,0 +1,30 @@ +package org.merlin.tertis.game + +import com.badlogic.gdx.Input.Keys +import com.badlogic.gdx.InputAdapter + +class OverControl(over: Over) extends InputAdapter { + + override def keyDown(keycode: Int): Boolean = { + if (keycode == Keys.ESCAPE || keycode == Keys.BACK) { + over.home() + } + true + } + + override def keyUp(keycode: Int): Boolean = { + if (keycode == Keys.SPACE || keycode == Keys.ENTER) { + over.home() + } + true + } + override def touchUp( + screenX: Int, + screenY: Int, + pointer: Int, + button: Int + ): Boolean = { + over.home() + true + } +} diff --git a/core/src/org/merlin/tertis/game/Player.scala b/core/src/org/merlin/tertis/game/Player.scala new file mode 100644 index 0000000..0689fb7 --- /dev/null +++ b/core/src/org/merlin/tertis/game/Player.scala @@ -0,0 +1,185 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.Gdx.input +import com.badlogic.gdx.Input.Peripheral +import com.badlogic.gdx.graphics.g2d.{BitmapFont, PolygonSpriteBatch} +import org.merlin.tertis.Geometry._ +import org.merlin.tertis.Tertis + +case class BlockLoc(block: Block, rotation: Int, column: Int, y: Float) + +object BlockLoc { + def apply(block: Block): BlockLoc = + BlockLoc(block, 0, (10 - block.size) / 2, Dimension * Rows) +} + +class Player(game: Game) { + // how many seconds has this been touched down + var touchdown: Option[Float] = None + // was this piece played all speeidly + var speedy: Boolean = true + + var blockOpt: Option[BlockLoc] = None + + def next(block: Block): Unit = { + speedy = true + blockOpt = Some(BlockLoc(block)) + } + + def draw(batch: PolygonSpriteBatch): Unit = { + blockOpt foreach { loc => + loc.block.eachSquare( + loc.rotation, + (i, j, test) => + BlockRenderer.render( + batch, + loc.block.getColor, + OffsetX + (loc.column + i) * Dimension, + OffsetY + loc.y.toInt + j * Dimension, + Dimension, + Dimension, + Bevel, + test + ) + ) + } + } + + def update(delta: Float): Unit = + blockOpt.foreach(update(delta, _)) + + def update(delta: Float, oldLoc: BlockLoc): Unit = { + val now = System.currentTimeMillis + val fastness = + game.fast.fold(1f, Prefs.TiltSpeed.fold(tiltSpeed, 0f)) + if (fastness < .8f) speedy = false + + val speedup = + 1f + game.zenMode.fold( + 0f, + game.score.count.toFloat / 200 + ) // double speed after 200 pieces = 50 rows, triple speed after 400 etc + val velocityY = game.gravity.fold( + GravitySpeed, + SlowSpeed + (FastSpeed - SlowSpeed) * fastness * speedup + ) + // if you move two dimension units you could jump through blocks + val deltaY = (velocityY * Dimension * delta) min (Dimension * 15 / 8) + val newY = oldLoc.y - deltaY + + val floorY = + Some(quantize(oldLoc.y)).filter(_ > newY) // floor of y if above newY + val newColumn = game.shift + .orElse(game.autoShift.filter(_.timestamp < now - AutoRepeatMillis)) + .filter(shift => shift.auto || shift.timestamp > now - KeyDurationMillis) + .map(change => oldLoc.column + change.value) + val newRotation = game.rotate + .filter(_.timestamp > now - KeyDurationMillis) + .map(change => (oldLoc.rotation + 4 + change.value) % 4) + + // search all combinations of shifts rotates and moves, including into any position + // that we passed on this slide down + val newLocations = for { + column <- optList(newColumn, oldLoc.column) + rotation <- optList(newRotation, oldLoc.rotation) + y <- optList(floorY, newY).reverse + } yield oldLoc.copy(rotation = rotation, column = column, y = y) + + newLocations.find(isValid(game, _)) match { + case Some(newLoc) => + blockOpt = Some(newLoc) + val shifted = newLoc.column != oldLoc.column + // drop the shift if it's a one-off that succeeded or it's an auto that did not + game.shift = game.shift.filter(_.auto == shifted) + if (newLoc.rotation != oldLoc.rotation) + game.rotate = None // wrong + if (newLoc.y > newY) { // didn't move full amount so hit something + if (touchdown.exists(_ >= GracePeriodSeconds)) { + game.board.drop( + newLoc.block, + newLoc.rotation, + newLoc.column, + (newLoc.y / Dimension).floor.toInt + ) + touchdown = None + blockOpt = None + game.gravity = false + game.score.drop(speedy) + } else { + touchdown = touchdown.map(_ + delta).orElse(Some(0f)) + } + } else { + touchdown = None + } + // In addition to clicking after you have just touched down, we deliver haptic and audio feedback if you + // would touch down next frame. This gives you a few milliseconds warning.. We have to use slow + // speed lest you drop to slow speed next frame and we have clicked prematurely + val nextLoc = newLoc.copy(y = newLoc.y - SlowSpeed * Dimension * delta) + if (touchdown.isDefined || !isValid(game, nextLoc)) { + if (!game.clickPlayed) { + game.clickPlayed = true + dropClick((velocityY / FastSpeed) min 1f, newLoc.y) + } + } else { + game.clickPlayed = false + } + case None => + game.shift = game.shift.filterNot(_.auto) + // initial piece placement invalid => endgame + if (!Prefs.MuteAudio.isTrue) + Tertis.end.play(1f) + game.score.recordHighScore() + game.state = Game.LostState + + } + } + + def dropClick(fastness: Float, y: Float): Unit = { + if (!Prefs.MuteAudio.isTrue) { + val volume = .25f + .5f * fastness + val pitch = 1f + PitchShift * y / (Dimension * Rows) + val pan = 0f + Tertis.drop.play(volume, pitch, pan) + if (input.isPeripheralAvailable(Peripheral.Vibrator)) + input.vibrate(10) + } + } + + private def tiltSpeed: Float = // -45 is 1f, -15 is 0f + (-input.getPitch / 30f - .5f) max 0f min 1f + + private def quantize(y: Float): Float = Dimension * (y / Dimension).floor + + private def optList[A](opt: Option[A], a: A): List[A] = + opt.fold(List(a))(a0 => List(a0, a)) + + private def isValid(game: Game, loc: BlockLoc): Boolean = + loc.block.forall( + loc.rotation, + (i, j) => { + val column = loc.column + i + val row0 = Math.floor(loc.y / Dimension).toInt + j + val row1 = Math.ceil(loc.y / Dimension).toInt + j + column >= 0 && column < Columns && row0 >= 0 && !game.board + .test(column, row0) && !game.board.test(column, row1) + } + ) + + // blocks per second + val SlowSpeed = 4f + val FastSpeed = 16f + val GravitySpeed = 60f + + // for how many milliseconds is an action key valid (i.e. will effect if it becomes possible within this period) + val KeyDurationMillis = 100L + // how long before autorepeat kicks in + val AutoRepeatMillis = 300L + // for how long can you manipulate a landed piece + val GracePeriodSeconds = .2f + + // 0f-1f how much to shift the pitch up at the top + val PitchShift = .1f + + val font = new BitmapFont() +} diff --git a/core/src/org/merlin/tertis/game/Score.scala b/core/src/org/merlin/tertis/game/Score.scala new file mode 100644 index 0000000..dc3a88a --- /dev/null +++ b/core/src/org/merlin/tertis/game/Score.scala @@ -0,0 +1,74 @@ +package org.merlin.tertis +package game + +import com.badlogic.gdx.graphics.g2d.{GlyphLayout, PolygonSpriteBatch} +import org.merlin.tertis.Geometry._ +import org.merlin.tertis.{Prefs, Tertis} + +// on phone fwiw 12 minutes for 120 rows +class Score { + var alpha: Float = 0f + var time: Float = 0f + var score: Int = 0 + var count: Int = 0 + var rows: Int = 0 + var speedRun: Int = 0 + var highScore: Boolean = false + + // TODO: gravity assist should be equivalent to speedy if you are quick about it. zen lower score? + + def drop(speedy: Boolean): Unit = { + count = count + 1 + speedRun = speedy.fold(1 + speedRun, 0) + score = score + speedRun + } + + // an epic drop would be worth 4 * 4 * 184 = 2944 points before speed multiplier + def cleared(rows: Int, mass: Int): Unit = { + this.rows = this.rows + rows + val speedX = + speedRun / 5 // integer arithmetic so no bonus until after at least 5 + score = score + rows * rows * mass * (1 + speedX) + } + + def recordHighScore(): Unit = { + if (score > 0 && Prefs.HighScore.intValue.forall(_ < score)) { + highScore = true + Prefs.HighScore.set(score) + Prefs.HighTime.set(time.intValue) + Prefs.HighRows.set(rows.intValue) + } + Prefs.AllTime.set( + Prefs.AllTime.longValue.fold(time.longValue)(_ + time.longValue) + ) + } + + def update(delta: Float): Unit = { + alpha = (alpha + delta / FadeInSeconds) min 1f + time = time + delta + } + + def draw(batch: PolygonSpriteBatch): Unit = { + Text.smallFont.setColor(1, 1, 1, alpha * alpha) + Text.mediumFont.setColor(1, 1, 1, alpha * alpha) + val scoreLabel = new GlyphLayout(Text.smallFont, f"SCORE:") + val scoreValue = + new GlyphLayout(Text.mediumFont, f" $score%,d") + val xOffset = OffsetX + Dimension / 4 + val baseline = OffsetY + Dimension * Rows + Dimension / 2 + Text.smallFont.draw( + batch, + scoreLabel, + xOffset, + baseline + Text.smallFont.getCapHeight + ) + Text.mediumFont.draw( + batch, + scoreValue, + xOffset + scoreLabel.width, + baseline + Text.mediumFont.getCapHeight + ) + } + + val FadeInSeconds = 1f +} diff --git a/core/src/org/merlin/tertis/home/Help.scala b/core/src/org/merlin/tertis/home/Help.scala new file mode 100644 index 0000000..919a002 --- /dev/null +++ b/core/src/org/merlin/tertis/home/Help.scala @@ -0,0 +1,216 @@ +package org.merlin.tertis +package home + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Geometry._ +import org.merlin.tertis.Scene +import org.merlin.tertis.common.{Frame, Starfield} +import org.merlin.tertis.game.Game +import org.merlin.tertis.util.TextureWrapper + +// I don't like that this fades in after home fades out, but modeling this as a separate scene +// makes life so much easier. +class Help(home: Home, game: Option[Game] = None) extends Scene { + + import Help._ + + var state: State = HelpState + var alpha: Float = 0f + var instructed: Float = 0f + + private val IconSize = Dimension * 3 / 4 + + val closeIcon = List( + new BasicIcon( + Gdx.graphics.getWidth - IconSize * 2, + Gdx.graphics.getHeight - IconSize * 2, + IconSize, + Tertis.close, + () => { + state = ExitState + } + ) + ) + + def icons: List[Icon] = game.isDefined.fold(Nil, closeIcon) + + override def init(): HelpControl = + new HelpControl(this) + + override def update(delta: Float): Option[Scene] = { + Starfield.update(delta) + Frame.update(delta) + if (state == HelpState) { + alpha = alpha.alphaUp(delta, InstructionsFadeInSeconds) + if (game.isDefined) { + instructed = instructed + delta + if (instructed >= AutoInstructionsSeconds) continue() + } + None + } else { + alpha = alpha.alphaDown(delta, InstructionsFadeOutSeconds) + val awaitFrame = (state == ContinueState) && game.isDefined + (alpha == 0f && (!awaitFrame || Frame.alpha == 1f)) + .option(game.filter(_ => state == ContinueState).getOrElse(home)) + } + } + + override def render(batch: PolygonSpriteBatch): Unit = { + Starfield.render(batch) + + if (Tertis.mobile) { + mobileHelp(batch) + } else { + desktopIcons.foreach(_.draw(batch, alpha * alpha)) + } + icons.foreach(_.draw(batch, alpha * alpha)) + + Frame.render(batch) + } + + private val DesktopIconLeft = Dimension * 3 + private val DesktopIconInterval = IconSize * 2 + private val DesktopIconsTop = + (Gdx.graphics.getHeight + (DesktopIconInterval * 4 + IconSize)) / 2 + + val desktopIcons: List[Icon] = List( + new KeyIcon( + DesktopIconLeft, + DesktopIconsTop, + IconSize, + Tertis.arrowKey, + 0f, + "Right" + ), + new KeyIcon( + DesktopIconLeft, + DesktopIconsTop - DesktopIconInterval, + IconSize, + Tertis.arrowKey, + 180f, + "Left" + ), + new KeyIcon( + DesktopIconLeft, + DesktopIconsTop - DesktopIconInterval * 2, + IconSize, + Tertis.arrowKey, + 90f, + "Rotate" + ), + new KeyIcon( + DesktopIconLeft, + DesktopIconsTop - DesktopIconInterval * 3, + IconSize, + Tertis.arrowKey, + 270f, + "Drop" + ), + new KeyIcon( + DesktopIconLeft, + DesktopIconsTop - DesktopIconInterval * 4, + IconSize, + Tertis.metaKey, + 0f, + "Velocitator" + ) + ) + + private val mobileHelps = List( + MobileHelp(Tertis.tap, "Left", "Tap", 0, 1), + MobileHelp(Tertis.swipeUpDown, "Rotate", "Swipe up/down", 0, 3), + MobileHelp(Tertis.tap, "Right", "Tap", 2, 1), + MobileHelp(Tertis.swipeUpDown, "Rotate", "Swipe up/down", 2, 3), + MobileHelp(Tertis.swipeLeft, "Slide left", "Swipe left", 1, 0), + MobileHelp(Tertis.swipeRight, "Slide right", "Swipe right", 1, 2), + MobileHelp(Tertis.swipeDown, "Drop", "Swipe down", 1, 4) + ) + + private def mobileHelp(batch: PolygonSpriteBatch): Unit = { + val IconSize = Dimension + val color = Icon.White ⍺⍺ alpha + val grey = Icon.Grey ⍺⍺ alpha + val columnSpacing = Dimension / 2 + val columnWidth = (Gdx.graphics.getWidth - columnSpacing * 6) / 3 + val scale = IconSize.toFloat / 512 + val helpEntryHeight = + Text.smallFont.getLineHeight + Text.tinyFont.getLineHeight + Dimension * 5 / 4 + val totalHeight = helpEntryHeight * 3 + 2 * Dimension * 2 + val initialY = + Gdx.graphics.getHeight - (Gdx.graphics.getHeight - totalHeight) / 2 + batch.setColor(color) + Text.smallFont.setColor(color) + mobileHelps.foreach { help => + val w = help.icon.width * scale + val h = help.icon.height * scale + val x = columnSpacing + (columnWidth + columnSpacing * 2) * help.x + val y = initialY - 2 * Dimension * help.y + Text.draw(batch, Text.smallFont, color, help.label, y, x, columnWidth) + batch.draw( + help.icon, + x + (columnWidth - w) / 2, + y - Text.smallFont.getLineHeight - (h + IconSize) / 2, + w, + h + ) + Text.draw( + batch, + Text.tinyFont, + grey, + help.desc, + y - Text.smallFont.getLineHeight - Dimension * 5 / 4, + x, + columnWidth + ) + } + batch.draw( + Tertis.separator, + columnWidth + columnSpacing * 2 - Dimension / 32, + initialY - totalHeight, + Dimension / 16, + totalHeight + ) + batch.draw( + Tertis.separator, + columnWidth * 2 + columnSpacing * 4 - Dimension / 32, + initialY - totalHeight, + Dimension / 16, + totalHeight + ) + + } + + def exit(): Unit = { + state = ExitState + } + + def continue(): Unit = { + if (game.isDefined) Frame.targetAlpha = 1f + state = ContinueState + } +} + +object Help { + val InstructionsFadeInSeconds = .3f + val InstructionsFadeOutSeconds = .3f + val AutoInstructionsSeconds = 5f + + val Red = new Color(.855f, .075f, .102f, 1f) + val Yellow = new Color(1f, .937f, 0f, 1f) + val White = new Color(.7f, .7f, .7f, 1f) + + sealed trait State + case object HelpState extends State + case object ExitState extends State + case object ContinueState extends State + + private final case class MobileHelp( + icon: TextureWrapper, + label: String, + desc: String, + x: Int, + y: Int + ) +} diff --git a/core/src/org/merlin/tertis/home/HelpControl.scala b/core/src/org/merlin/tertis/home/HelpControl.scala new file mode 100644 index 0000000..014bea2 --- /dev/null +++ b/core/src/org/merlin/tertis/home/HelpControl.scala @@ -0,0 +1,29 @@ +package org.merlin.tertis.home + +import com.badlogic.gdx.Input.Keys + +class HelpControl(help: Help) extends IconAdapter(help.icons) { + override def touchUp( + screenX: Int, + screenY: Int, + pointer: Int, + button: Int + ): Boolean = { + help.continue() + true + } + + override def keyDown(keycode: Int): Boolean = { + if (keycode == Keys.ESCAPE || keycode == Keys.BACK) { + help.exit() + } + true + } + + override def keyUp(keycode: Int): Boolean = { + if (keycode == Keys.SPACE || keycode == Keys.ENTER) { + help.continue() + } + true + } +} diff --git a/core/src/org/merlin/tertis/home/Home.scala b/core/src/org/merlin/tertis/home/Home.scala new file mode 100644 index 0000000..dc0cec2 --- /dev/null +++ b/core/src/org/merlin/tertis/home/Home.scala @@ -0,0 +1,227 @@ +package org.merlin.tertis +package home + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Geometry._ +import org.merlin.tertis.common.{Frame, Starfield} +import org.merlin.tertis.game.Game +import org.merlin.tertis.{Scene, Tertis} + +import scala.concurrent.duration.DurationInt + +class Home(paused: Option[Game] = None) extends Scene { + + import Home._ + + var state: State = HomeState + var logoAlpha = 0f + var playAlpha = 0f + var discard = false + + // spring layout would make this easy + + private val IconSize = Dimension * 3 / 4 + private val IconCount = 3 + private val IconMargin = + (Gdx.graphics.getWidth - IconCount * IconSize) / (IconCount + 1) + private val IconOffsetX = IconMargin + IconSize / 2 + private val IconSpacing = IconMargin + IconSize + + private val HighScoreSize = + (Text.smallFont.getLineHeight + Text.tinyFont.getLineHeight).toInt + private val LogoWidth = Gdx.graphics.getWidth * 2 / 3 + + private val FooterMargin = + (Gdx.graphics.getHeight - LogoWidth) / 4 + private val IconOffsetY = + Gdx.graphics.getHeight - (Gdx.graphics.getHeight - LogoWidth) / 4 + + private val baseIcons: List[Icon] = List( + new PlayIcon( + Gdx.graphics.getWidth / 2, + Gdx.graphics.getHeight / 2, + LogoWidth / 2, + this + ), + new PrefIcon( + IconOffsetX, + IconOffsetY, + IconSize, + Prefs.MuteAudio, + Tertis.soundOff, + Tertis.soundOn + ), +// new PrefIcon( +// IconOffsetX + IconSpacing, +// IconOffsetY, +// IconSize, +// Prefs.MuteMusic, +// Tertis.musicOff, +// Tertis.musicOn +// ), + new BasicIcon( + IconOffsetX + IconSpacing, + IconOffsetY, + IconSize, + Tertis.settings, + () => { + state = SettingsState + } + ), + new BasicIcon( + IconOffsetX + IconSpacing * 2, + IconOffsetY, + IconSize, + Tertis.help, + () => { + state = HelpState + } + ) + ) + + private val iconsWithDiscard = new BasicIcon( + Gdx.graphics.getWidth / 2 - Dimension * 9 / 4, // failure to get real dimensions + (FooterMargin + HighScoreSize - Text.tinyFont.getLineHeight / 2 - Text.smallFont.getAscent).toInt, + IconSize / 2, + Tertis.trash, + () => { + discard = true + }, + HighScoreColor + ) :: baseIcons + + def icons: List[Icon] = + (paused.isDefined && !discard).fold(iconsWithDiscard, baseIcons) + + override def init(): HomeControl = { + state = HomeState + Frame.targetAlpha = 0f + new HomeControl(this) + } + + override def update(delta: Float): Option[Scene] = { + Starfield.update(delta) + Frame.update(delta) + if (state == HomeState) { + logoAlpha = logoAlpha.alphaUp(delta, LogoFadeInSeconds) + if (logoAlpha > PlayDelaySeconds) + playAlpha = playAlpha.alphaUp(delta, PlayFadeInSeconds) + None + } else { + logoAlpha = logoAlpha.alphaDown(delta, LogoFadeOutSeconds) + playAlpha = playAlpha.alphaDown(delta, PlayFadeOutSeconds) + if (state == SettingsState) { + (logoAlpha + playAlpha == 0f) + .option(new Settings(this)) + } else if (state == PlayState) { + (logoAlpha + playAlpha == 0f && Frame.alpha == 1f) + .option(nextGame) + } else { + (logoAlpha + playAlpha == 0f).option( + new Help(this, (state == HelpPlayState).option(nextGame)) + ) + } + } + } + + private def nextGame: Game = + paused.filterNot(_ => discard).getOrElse(new Game) + + override def render(batch: PolygonSpriteBatch): Unit = { + Starfield.render(batch) + drawLogo(batch) + icons.foreach(_.draw(batch, playAlpha * playAlpha)) + if (paused.isDefined && !discard) { + drawPaused(batch) + } else { + for { + score <- Prefs.HighScore.intValue + time <- Prefs.HighTime.intValue + } drawHighScore(batch, score, time) + } + + Frame.render(batch) + } + + private def drawLogo(batch: PolygonSpriteBatch): Unit = { + val logoOffset = (Gdx.graphics.getWidth - LogoWidth) / 2 + batch.setColor(1, 1, 1, logoAlpha * logoAlpha) + batch.draw( + Tertis.logo, + logoOffset, + Gdx.graphics.getHeight / 2 - LogoWidth / 2, + LogoWidth, + LogoWidth + ) + } + + private def drawPaused( + batch: PolygonSpriteBatch + ): Unit = { + val color = HighScoreColor ⍺ (logoAlpha * logoAlpha) + Text.draw( + batch, + Text.smallFont, + color, + "Game Paused", + FooterMargin + HighScoreSize - Text.tinyFont.getLineHeight / 2 + ) + } + + private def drawHighScore( + batch: PolygonSpriteBatch, + score: Int, + time: Int + ): Unit = { + val color = HighScoreColor ⍺ (logoAlpha * logoAlpha) + Text.draw( + batch, + Text.smallFont, + color, + f"High Score: $score%,d", + FooterMargin + HighScoreSize + ) + Text.draw( + batch, + Text.tinyFont, + color, + time.seconds.toHumanString, + FooterMargin + HighScoreSize - Text.smallFont.getLineHeight + ) + } + + def play(): Unit = { + if (Prefs.Instructed.booleanValue.contains(true)) { + state = PlayState + Frame.targetAlpha = 1f + } else { + state = HelpPlayState + Prefs.Instructed.set(true) + } + } +} + +object Home { + def apply(game: Game): Home = new Home(Some(game)) + + val LogoFadeInSeconds = 1f + val PlayDelaySeconds = 0.3f + val PlayFadeInSeconds = .3f + + val LogoFadeOutSeconds = .5f + val PlayFadeOutSeconds = .3f + + val Title = "Тэятис" + + val HighScoreColor = new Color(.7f, .7f, .7f, 1f) + + sealed trait State + + case object HomeState extends State + case object HelpState extends State + case object HelpPlayState extends State + case object SettingsState extends State + case object PlayState extends State +} diff --git a/core/src/org/merlin/tertis/home/HomeControl.scala b/core/src/org/merlin/tertis/home/HomeControl.scala new file mode 100644 index 0000000..9fd5c41 --- /dev/null +++ b/core/src/org/merlin/tertis/home/HomeControl.scala @@ -0,0 +1,23 @@ +package org.merlin.tertis.home + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Input.Keys + +class HomeControl(home: Home) extends IconAdapter(home.icons) { + + override def keyDown(keycode: Int): Boolean = { + if (keycode == Keys.ESCAPE || keycode == Keys.BACK) { + Gdx.app.exit() + } + true + } + + override def keyUp(keycode: Int): Boolean = { + if (keycode == Keys.SPACE || keycode == Keys.ENTER) { + home.play() + } else if (keycode == Keys.SLASH) { + home.state = Home.HelpState + } + true + } +} diff --git a/core/src/org/merlin/tertis/home/Icon.scala b/core/src/org/merlin/tertis/home/Icon.scala new file mode 100644 index 0000000..f7c1671 --- /dev/null +++ b/core/src/org/merlin/tertis/home/Icon.scala @@ -0,0 +1,200 @@ +package org.merlin.tertis +package home + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Geometry.Dimension +import org.merlin.tertis.home.Icon.White +import org.merlin.tertis.util.TextureWrapper + +trait Icon { + import Icon._ + + def draw(batch: PolygonSpriteBatch, alpha: Float): Unit + def x: Int + def y: Int + def size: Int + def onPress(): Unit = () + def onRelease(inside: Boolean): Unit = () + + protected def draw( + batch: PolygonSpriteBatch, + alpha: Float, + texture: TextureWrapper, + color: Color = White + ): Unit = { + batch.setColor(color ⍺ alpha) + batch.draw(texture, x - size / 2, y - size / 2, size, size) + } +} + +object Icon { + val White = new Color(1f, 1f, 1f, 1f) + val Grey = new Color(.4f, .4f, .4f, 1f) +} + +abstract class BaseIcon(disabled: Boolean = false) extends Icon { + var pressed = false + + override def onPress(): Unit = { + if (!disabled) { + if (!Prefs.MuteAudio.isTrue) + Tertis.click.play(.125f) + pressed = true + } + } + + override def onRelease(inside: Boolean): Unit = { + if (!disabled) { + pressed = false + if (inside) clicked() + } + } + + protected def clicked(): Unit +} + +class PrefIcon( + val x: Int, + val y: Int, + val size: Int, + pref: Pref, + ifTrue: TextureWrapper, + ifFalse: TextureWrapper +) extends BaseIcon { + + override def draw(batch: PolygonSpriteBatch, alpha: Float): Unit = + draw(batch, alpha * pressed.fold(.5f, 1f), pref.fold(ifTrue, ifFalse)) + + override def clicked(): Unit = { + pref.set(!pref.booleanValue.isTrue) + } +} + +class BasicIcon( + val x: Int, + val y: Int, + val size: Int, + texture: TextureWrapper, + callback: () => Unit, + color: Color = White +) extends BaseIcon { + + override def draw(batch: PolygonSpriteBatch, alpha: Float): Unit = + draw(batch, alpha * pressed.fold(.5f, 1f), texture, color) + + override def clicked(): Unit = { + callback() + } +} + +class CheckIcon( + val x: Int, + val y: Int, + val size: Int, + pref: Pref, + label: String, + description: String, + disabled: Boolean = false +) extends BaseIcon(disabled) { + + override def draw(batch: PolygonSpriteBatch, alpha: Float): Unit = { + val color = disabled.fold(Icon.Grey, Icon.White) + draw( + batch, + alpha * pressed.fold(.5f, 1f), + pref.fold(Tertis.checkOn, Tertis.checkOff), + color + ) + Text.smallFont.setColor(color ⍺ alpha) + val textY = + y + (Text.smallFont.getLineHeight + Text.tinyFont.getAscent - Text.tinyFont.getDescent) / 2 + Text.smallFont.draw(batch, label, x + size * 1.25f, textY) + Text.tinyFont.setColor(color ⍺ alpha) + Text.tinyFont.draw( + batch, + description, + x + size * 1.25f, + textY - Text.smallFont.getLineHeight + ) + + } + + override def clicked(): Unit = { + pref.set(!pref.booleanValue.isTrue) + } +} + +class KeyIcon( + val x: Int, + val y: Int, + val size: Int, + icon: TextureWrapper, + rotation: Float, + label: String +) extends BaseIcon { + + override def draw(batch: PolygonSpriteBatch, alpha: Float): Unit = { + batch.setColor(White ⍺ alpha) + batch.draw( + icon, + x - size / 2, + y - size / 2, + size / 2, + size / 2, + size, + size, + 1f, + 1f, + rotation, + 0, + 0, + icon.width, + icon.height, + false, + false + ) + Text.smallFont.setColor(White ⍺ alpha) + val textY = y + Text.smallFont.getAscent + Text.smallFont.draw(batch, label, x + size * 1.25f, textY) + } + + override def clicked(): Unit = () +} + +class PlayIcon(val x: Int, val y: Int, val size: Int, home: Home) + extends BaseIcon { + override def draw(batch: PolygonSpriteBatch, alpha: Float): Unit = { + val playScale = alpha * alpha * (if (pressed) .95f else 1f) + val playWidth = playScale * Gdx.graphics.getWidth / 6 + val playHeight = Tertis.play.height * playWidth / Tertis.play.width + batch.setColor(1, 1, 1, alpha * alpha) + val (dX, dY) = if (compassAvailable) compassShift else (0f, 0f) + batch.draw( + Tertis.play, + x - playWidth / 3 + dX, + y - playHeight / 2 + dY, + playWidth, + playHeight + ) + } + + // TODO: temporally smooth this? + private def compassShift: (Float, Float) = { + val roll = Gdx.input.getRoll // -180 to 180 + val pitch = Gdx.input.getPitch // -90 to 90 + val scale = Dimension / 4f / 90f + // as pitch approaches 90, roll becomes indeterminate so ramp to 0 from 75 to 85 + val pitchLimit = + if (pitch.abs > 85f) 0f + else if (pitch.abs < 75f) 1f + else (85f - pitch.abs) / 10f + ( + (roll max -90f min 90f) * scale * pitchLimit * pitchLimit, + (pitch + 45) * scale + ) + } + + override def clicked(): Unit = home.play() +} diff --git a/core/src/org/merlin/tertis/home/IconAdapter.scala b/core/src/org/merlin/tertis/home/IconAdapter.scala new file mode 100644 index 0000000..92a3c23 --- /dev/null +++ b/core/src/org/merlin/tertis/home/IconAdapter.scala @@ -0,0 +1,42 @@ +package org.merlin.tertis.home + +import com.badlogic.gdx.{Gdx, InputAdapter} + +import scala.collection.mutable + +abstract class IconAdapter(icons: => List[Icon]) extends InputAdapter { + + private val down = mutable.Map.empty[Int, Icon] + + override def touchDown( + screenX: Int, + screenY: Int, + pointer: Int, + button: Int + ): Boolean = { + icons.find(icon => + within(screenX, screenY, icon.x, icon.y, icon.size) + ) foreach { icon => + icon.onPress() + down.put(pointer, icon) + } + true + } + + override def touchUp( + screenX: Int, + screenY: Int, + pointer: Int, + button: Int + ): Boolean = { + down.remove(pointer) foreach { icon => + icon.onRelease(within(screenX, screenY, icon.x, icon.y, icon.size)) + } + true + } + + // I deliberately pass the full width of the icon as its radius so the touch area is bigger + def within(screenX: Int, screenY: Int, x: Int, y: Int, radius: Int): Boolean = + (x - screenX) * (x - screenX) + (Gdx.graphics.getHeight - screenY - y) * (Gdx.graphics.getHeight - screenY - y) < radius * radius + +} diff --git a/core/src/org/merlin/tertis/home/Settings.scala b/core/src/org/merlin/tertis/home/Settings.scala new file mode 100644 index 0000000..ee4093c --- /dev/null +++ b/core/src/org/merlin/tertis/home/Settings.scala @@ -0,0 +1,94 @@ +package org.merlin.tertis +package home + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.g2d.PolygonSpriteBatch +import org.merlin.tertis.Geometry.Dimension +import org.merlin.tertis.Scene +import org.merlin.tertis.common.{Frame, Starfield} + +// I don't like that this fades in after home fades out, but modeling this as a separate scene +// makes life so much easier. +class Settings(home: Home) extends Scene { + import Settings._ + + var alpha: Float = 0f + var done: Boolean = false + + private val IconSize = Dimension * 3 / 4 + private val IconTop = Gdx.graphics.getHeight - IconSize * 5 + private val IconSpacing = IconSize * 3 + + val icons: List[Icon] = List( + new BasicIcon( + Gdx.graphics.getWidth - IconSize * 2, + Gdx.graphics.getHeight - IconSize * 2, + IconSize, + Tertis.close, + () => { + done = true + } + ), + new CheckIcon( + IconSize * 2, + IconTop, + IconSize, + Prefs.ZenMode, + "Zen mode", + "Slow and steady wins the race." + ), + new CheckIcon( + IconSize * 2, + IconTop - IconSpacing, + IconSize, + Prefs.TiltSpeed, + "Tilt speed", + "Tilt your phone to change the speed.", + !Tertis.mobile + ), +// new CheckIcon( +// IconSize * 2, +// IconTop - IconSpacing * 2, +// IconSize, +// Prefs.StuffHappens, +// "Stuff happens", +// "Stuff happens while you play." +// ), + new CheckIcon( + IconSize * 2, + IconTop - IconSpacing * 2, + IconSize, + Prefs.HighContrast, + "High contrast", + "More vivid colours." + ), + ) + + override def init(): SettingsControl = + new SettingsControl(this) + + override def update(delta: Float): Option[Scene] = { + Starfield.update(delta) + Frame.update(delta) + if (!done) { + alpha = alpha.alphaUp(delta, SettingsFadeInSeconds) + None + } else { + alpha = alpha.alphaDown(delta, SettingsFadeOutSeconds) + (alpha == 0f) + .option(home) + } + } + + override def render(batch: PolygonSpriteBatch): Unit = { + Starfield.render(batch) + icons.foreach(_.draw(batch, alpha * alpha)) + Frame.render(batch) + } + +} + +object Settings { + val SettingsFadeInSeconds = .3f + val SettingsFadeOutSeconds = .3f +} diff --git a/core/src/org/merlin/tertis/home/SettingsControl.scala b/core/src/org/merlin/tertis/home/SettingsControl.scala new file mode 100644 index 0000000..935538a --- /dev/null +++ b/core/src/org/merlin/tertis/home/SettingsControl.scala @@ -0,0 +1,12 @@ +package org.merlin.tertis.home + +import com.badlogic.gdx.Input.Keys + +class SettingsControl(settings: Settings) extends IconAdapter(settings.icons) { + override def keyDown(keycode: Int): Boolean = { + if (keycode == Keys.ESCAPE || keycode == Keys.BACK) { + settings.done = true + } + true + } +} diff --git a/core/src/org/merlin/tertis/package.scala b/core/src/org/merlin/tertis/package.scala new file mode 100644 index 0000000..a1356c6 --- /dev/null +++ b/core/src/org/merlin/tertis/package.scala @@ -0,0 +1,92 @@ +package org.merlin + +import com.badlogic.gdx.Gdx.input +import com.badlogic.gdx.Input.Peripheral +import com.badlogic.gdx.graphics.Color + +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.FiniteDuration + +// Things kinda stolen from scaloi +package object tertis { + + def compassAvailable: Boolean = + input.isPeripheralAvailable(Peripheral.Compass) + + implicit class AnyOps(val self: Any) extends AnyVal { + + /** Replace this value with [a]. */ + def as[A](a: A): A = a + } + + implicit class FloatOps(val self: Float) extends AnyVal { + + /** Clamp this value between 0f and 1f inclusive. */ + def clamp: Float = clamp(1f) + + /** Clamp this value between 0f and [max] inclusive. */ + def clamp(max: Float): Float = + if (self < 0f) 0f else if (self > max) max else self + + /** Increases an alpha by [delta] time interval spread over [seconds] seconds limited to 1f. */ + def alphaUp(delta: Float, seconds: Float): Float = + (self + delta / seconds) min 1f + + /** Decreases an alpha by [delta] time interval spread over [seconds] seconds limited to 0f. */ + def alphaDown(delta: Float, seconds: Float): Float = + (self - delta / seconds) max 0f + } + + implicit class BooleanOps(val self: Boolean) extends AnyVal { + def option[A](a: => A): Option[A] = if (self) Some(a) else None + def fold[A](ifTrue: => A, ifFalse: => A): A = if (self) ifTrue else ifFalse + } + + implicit class FiniteDurationOps(val self: FiniteDuration) extends AnyVal { + def toFiniteDuration(tu: TimeUnit): FiniteDuration = + FiniteDuration(self.toUnit(tu).toLong, tu) + + protected def largestUnit: Option[TimeUnit] = + TimeUnit.values.findLast(u => self.toUnit(u) >= 1.0) + + def toHumanString: String = { + largestUnit.fold("no time at all") { u => + val scaled = toFiniteDuration(u) + scaled.toString + val v = TimeUnit.values.apply(u.ordinal - 1) + val modulus = FiniteDuration(1, u).toUnit(v).toInt + val remainder = self.toUnit(v).toLong % modulus + if (remainder > 0) + scaled.toString + ", " + FiniteDuration(remainder, v) + else + scaled.toString + } + } + } + + implicit class OptionOps[A](val self: Option[A]) extends AnyVal { + def isTrue(implicit Booleate: Booleate[A]): Boolean = + self.fold(false)(Booleate.value) + def isFalse(implicit Booleate: Booleate[A]): Boolean = + self.fold(false)(Booleate.unvalue) + } + + private trait Booleate[A] { + def value(a: A): Boolean + final def unvalue(a: A): Boolean = !value(a) + } + + private object Booleate { + implicit def booleate: Booleate[Boolean] = b => b + } + + implicit class ColorOps(val self: Color) extends AnyVal { + def ⍺(alpha: Float): Color = + new Color(self.r, self.g, self.b, self.a * alpha) + + def ⍺⍺(alpha: Float): Color = + new Color(self.r, self.g, self.b, self.a * alpha * alpha) + } + + // implicit def optionOps[A](a: Option[A]): OptionOps[A] = new OptionOps(a) +} diff --git a/core/src/org/merlin/tertis/util/LowPassAngleFilter.scala b/core/src/org/merlin/tertis/util/LowPassAngleFilter.scala new file mode 100644 index 0000000..faf6057 --- /dev/null +++ b/core/src/org/merlin/tertis/util/LowPassAngleFilter.scala @@ -0,0 +1,32 @@ +package org.merlin.tertis.util + +import com.badlogic.gdx.math.MathUtils + +// https://stackoverflow.com/a/18911252 +class LowPassAngleFilter { + import LowPassAngleFilter._ + + private var index = 0 + private val values = Array.fill(N)(0f) + private var sumSin = 0f + private var sumCos = 0f + var value = 0f + + def add(angle: Float): Unit = { + sumSin += MathUtils.sinDeg(angle) + sumCos += MathUtils.cosDeg(angle) + values.update(index % N, angle) + index = index + 1 + if (index > N) { + val old = values(index % N) + sumSin -= MathUtils.sinDeg(old) + sumCos -= MathUtils.cosDeg(old) + } + val size = index min N + value = MathUtils.radDeg * MathUtils.atan2(sumSin / size, sumCos / size) + } +} + +object LowPassAngleFilter { + private final val N = 20 +} diff --git a/core/src/org/merlin/tertis/util/LowPassQuaternionFilter.scala b/core/src/org/merlin/tertis/util/LowPassQuaternionFilter.scala new file mode 100644 index 0000000..73e2e3f --- /dev/null +++ b/core/src/org/merlin/tertis/util/LowPassQuaternionFilter.scala @@ -0,0 +1,19 @@ +package org.merlin.tertis.util + +import com.badlogic.gdx.math.{Matrix3, Quaternion} + +class LowPassQuaternionFilter(n: Int) { + private var index = 0 + private val values = Array.fill(n)(new Quaternion()) + val value = new Quaternion() + val conjugate = new Quaternion() + + def add(matrix: Matrix3): Unit = { + values(index).setFromMatrix(matrix) +// values(index).x = -values(index).x +// values(index).y = -values(index).y + index = (index + 1) % n + value.slerp(values) // yeah yeah, first few values will be off + conjugate.set(value).conjugate() + } +} diff --git a/core/src/org/merlin/tertis/util/TextureWrapper.scala b/core/src/org/merlin/tertis/util/TextureWrapper.scala new file mode 100644 index 0000000..74e0bbf --- /dev/null +++ b/core/src/org/merlin/tertis/util/TextureWrapper.scala @@ -0,0 +1,28 @@ +package org.merlin.tertis.util + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.graphics.{Pixmap, Texture} + +class TextureWrapper(val pixmap: Pixmap) { + + val width = pixmap.getWidth + val height = pixmap.getHeight + val texture = new Texture(pixmap) + + def dispose(): Unit = { + texture.dispose() + pixmap.dispose() + } + +} + +object TextureWrapper { + def load(path: String): TextureWrapper = { + val fileHandle = Gdx.files.internal(path) + val pixmap = new Pixmap(fileHandle) + new TextureWrapper(pixmap) + } + + implicit def toTexture(wrapper: TextureWrapper): Texture = wrapper.texture + +} diff --git a/desktop/build.gradle b/desktop/build.gradle new file mode 100644 index 0000000..b8c7e8c --- /dev/null +++ b/desktop/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'scala' +} + +sourceCompatibility = JavaVersion.VERSION_1_8 +targetCompatibility = JavaVersion.VERSION_1_8 + +compileScala { + scalaCompileOptions.optimize = true + scalaCompileOptions.additionalParameters = ['-target:jvm-1.8', '-feature', + '-language:postfixOps', '-language:implicitConversions'] +} + +dependencies { + implementation 'org.scala-lang:scala-library:2.13.8' +} + +sourceSets.main.scala.srcDirs = [ "src/" ] +sourceSets.main.resources.srcDirs = ["../assets"] + +project.ext.mainClassName = "org.merlin.tertis.DesktopLauncher" +project.ext.assetsDir = new File("../assets") + +import org.gradle.internal.os.OperatingSystem + +task run(dependsOn: classes, type: JavaExec) { + main = project.mainClassName + classpath = sourceSets.main.runtimeClasspath + standardInput = System.in + workingDir = project.assetsDir + ignoreExitValue = true + + if (OperatingSystem.current() == OperatingSystem.MAC_OS) { + // Required to run on macOS + jvmArgs += "-XstartOnFirstThread" + } +} + +task debug(dependsOn: classes, type: JavaExec) { + main = project.mainClassName + classpath = sourceSets.main.runtimeClasspath + standardInput = System.in + workingDir = project.assetsDir + ignoreExitValue = true + debug = true +} + +task dist(type: Jar) { + duplicatesStrategy(DuplicatesStrategy.EXCLUDE) + manifest { + attributes 'Main-Class': project.mainClassName + } + dependsOn configurations.runtimeClasspath + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + with jar +} + + +dist.dependsOn classes + +eclipse.project.name = appName + "-desktop" diff --git a/desktop/src/org/merlin/tertis/DesktopLauncher.scala b/desktop/src/org/merlin/tertis/DesktopLauncher.scala new file mode 100644 index 0000000..079bd6a --- /dev/null +++ b/desktop/src/org/merlin/tertis/DesktopLauncher.scala @@ -0,0 +1,14 @@ +package org.merlin.tertis + +import com.badlogic.gdx.backends.lwjgl3.{ + Lwjgl3Application, + Lwjgl3ApplicationConfiguration +} + +// Please note that on macOS your application needs to be started with the -XstartOnFirstThread JVM argument +object DesktopLauncher extends App { + val config = new Lwjgl3ApplicationConfiguration + config.setForegroundFPS(60) + config.setWindowedMode(500, 1050) + new Lwjgl3Application(new Tertis, config) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ff329ac --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.daemon=true +org.gradle.jvmargs=-Xms128m -Xmx1500m +org.gradle.configureondemand=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 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..2e6e589 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/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. +# + +############################################################################## +# +# 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/master/subprojects/plugins/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 + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# 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 + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + 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 + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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 + +@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=. +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%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +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%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/logo/Tertis.svg b/logo/Tertis.svg new file mode 100644 index 0000000..967ba99 --- /dev/null +++ b/logo/Tertis.svg @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + T + + + + + + + + + + diff --git a/logo/Tertis2.svg b/logo/Tertis2.svg new file mode 100644 index 0000000..4cc452b --- /dev/null +++ b/logo/Tertis2.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo/Tertis2Round.svg b/logo/Tertis2Round.svg new file mode 100644 index 0000000..4421be9 --- /dev/null +++ b/logo/Tertis2Round.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo/Tertis3.svg b/logo/Tertis3.svg new file mode 100644 index 0000000..f02fc79 --- /dev/null +++ b/logo/Tertis3.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo/bg.png b/logo/bg.png new file mode 100644 index 0000000..2e5ddbf Binary files /dev/null and b/logo/bg.png differ diff --git a/logo/fg.png b/logo/fg.png new file mode 100644 index 0000000..8ea25e4 Binary files /dev/null and b/logo/fg.png differ diff --git a/logo/fg2.png b/logo/fg2.png new file mode 100644 index 0000000..7101c92 Binary files /dev/null and b/logo/fg2.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e6a9599 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include 'desktop', 'android', 'core'