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 @@
+
+
+
+
+# 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'