Compare commits

...

10 Commits

Author SHA1 Message Date
Martin Prokoph
c017d4c129 docs: update README with test cases 2025-09-13 22:41:12 +02:00
Martin Prokoph
b17e88f96d clean: improve code structure 2025-09-13 17:18:06 +02:00
Martin Prokoph
f881427a67 feat: save highscores per difficulty 2025-09-09 15:03:24 +02:00
Martin Prokoph
db537f84ae feat: less drastic difficulty scaling 2025-09-09 10:27:56 +02:00
Martin Prokoph
5490ff8b5d docs: update README 2025-09-09 10:25:23 +02:00
Martin Prokoph
f58cd493bb clean: housekeeping 2025-09-09 10:20:10 +02:00
Martin Prokoph
7cbe2466d8 fix: Windows support
eww...
2025-09-08 18:27:22 +02:00
Martin Prokoph
223c3356b9 feat: final game space sizing
Also fixes logo after changing ui scale
2025-09-06 22:20:47 +02:00
Martin Prokoph
5077f1c2ad fix: normalize music volume 2025-09-06 22:04:54 +02:00
Martin Prokoph
f05d0a9f45 fix(NightJson): correctly save empty maps 2025-09-06 21:38:48 +02:00
24 changed files with 263 additions and 208 deletions

View File

@@ -4,6 +4,7 @@ Erstellt für das Modul "Anwendungsorientierte Programmierung" im Studiengang Me
Dieser Code wurde vollständig von mir und ohne die Verwendung von LLMs geschrieben. Dieser Code wurde vollständig von mir und ohne die Verwendung von LLMs geschrieben.
An einigen Stellen wurde auf Wissen von StackOverflow und Baeldung zugegriffen dies ist immer in den JavaDocs gekennzeichnet. An einigen Stellen wurde auf Wissen von StackOverflow und Baeldung zugegriffen dies ist immer in den JavaDocs gekennzeichnet.
Die Commit-Historie ist [auf meinem Gitea](https://git.midnightdust.eu/Motschen/TetrisClone/commits/branch/main) einsehbar. Die Commit-Historie ist [auf meinem Gitea](https://git.midnightdust.eu/Motschen/TetrisClone/commits/branch/main) einsehbar.
Gradle ist als Build-Tool eingerichtet, um elegant Jar-Dateien zu bauen, dabei aber völlig optional.
Können Sie meinen Highscore schlagen? Können Sie meinen Highscore schlagen?
@@ -11,4 +12,36 @@ Können Sie meinen Highscore schlagen?
## Rechtliche Hinweise ## Rechtliche Hinweise
»Tetris« ist eine eingetragene Marke von The Tetris Company, Inc. »Tetris« ist eine eingetragene Marke von The Tetris Company, Inc.
Die Verwendung des Namens »Tetris« und des Spielkonzepts erfolgen lediglich zu Bildungszwecken. Die Verwendung des Namens »Tetris« und des Spielkonzepts erfolgen lediglich zu Bildungszwecken.
# Testbeispiele
## Bewegung
Spiel starten
1. Nach links verschieben mit `A` oder `←`
2. Nach rechts verschieben mit `D` oder `→`
3. Schneller nach unten mit `S` oder `↓`
4. Direkt nach unten mit `Leertaste`
5. Rotieren mit `W` oder `↑`
Bei jeder Bewegung sollte vor dem Umsetzen geprüft werden, ob die neue Position valide (Nicht überschneidend und nicht außerhalb des Spielraums) ist.
## Logik
Sobald eine Reihe vervollständigt wird, sollte sie verschwinden.
Wenn die Teile über den oberen Rand ragen, soll das Spiel beendet werden.
Im Vorschaufenster sollte immer das nächste Tetrominio angezeigt werden.
![Screenshot, der zeigt, welche Zeilen verschwinden sollen](assets/line_completion.png)
## Highscores
Sobald das Spiel beendet wird und der Score den aktuell höchsten Score überschreitet, sollte eine Aufforderung zum Eingeben des Namens erscheinen.
Nach dem Bestätigen erscheint der Name in der Highscore-Liste und wird über Spielsitzungen hinweg gespeichert.
![Screenshot des Highscore-Dialogs](assets/highscore_dialog.png)
## Benutzeroberfläche
Die Darstellung der Benutzeroberfläche kann in den Einstellungen angepasst werden.
Dabei lässt sich die Sprache und die GUI-Skalierung auswählen.
Ansonsten sind auch noch Einstellungen zur Lautstärke und Schwierigkeit vorhanden.
## Sounds und Musik
Sofern die Lautstärke angemessen eingestellt und ein Lautsprecher angeschlossen ist, sollten beim Absetzen der Tetrominos, sowie beim Vervollständigen einer/mehrerer Reihen Töne zu hören sein.
Auch Musik sollte abgespielt werden, sobald das Spiel gestartet wird.
Töne und Musik wurden im Zrythm-DAW erzeugt.

BIN
assets/highscore_dialog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/line_completion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,13 +1,22 @@
package eu.midnightdust.yaytris; package eu.midnightdust.yaytris;
import eu.midnightdust.yaytris.util.NightJson; import eu.midnightdust.yaytris.util.Difficulty;
import eu.midnightdust.yaytris.util.json.Comment;
import eu.midnightdust.yaytris.util.json.NightJson;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@SuppressWarnings("unused") // Comments are unused in code, but are added to the JSON file for readability
public class HighScores { public class HighScores {
private static final NightJson json = new NightJson(HighScores.class, "tetris_scores.json5"); private static final NightJson json = new NightJson(HighScores.class, "tetris_scores.json5");
public static Map<String, Integer> scores = new HashMap<>(); public static Comment c1 = new Comment("Highscores for each difficulty");
public static Map<String, Integer> Noob = new HashMap<>();
public static Map<String, Integer> Easy = new HashMap<>();
public static Map<String, Integer> Normal = new HashMap<>();
public static Map<String, Integer> Hard = new HashMap<>();
public static Map<String, Integer> Extreme = new HashMap<>();
public static Map<String, Integer> WTF = new HashMap<>();
/** /**
* Saves a new high score for the specified player * Saves a new high score for the specified player
@@ -16,10 +25,21 @@ public class HighScores {
* @param score the score to save * @param score the score to save
*/ */
public static void addScore(String playerName, int score) { public static void addScore(String playerName, int score) {
scores.put(playerName, score); diffToMap(Settings.difficulty).put(playerName, score);
write(); write();
} }
public static Map<String, Integer> diffToMap(Difficulty difficulty) {
switch (difficulty) {
case EASY: return Easy;
case NORMAL: return Normal;
case HARD: return Hard;
case EXTREME: return Extreme;
case WTF: return WTF;
default: return Noob;
}
}
public static void load() { public static void load() {
json.readJson(); json.readJson();
} }

View File

@@ -0,0 +1,22 @@
package eu.midnightdust.yaytris;
import javax.swing.UIManager;
public class Main {
/**
* The application's entry point.
* Initializes the UI library to match the system style.
* Also loads saved settings, translations, and highscores from JSON.
*
* @param args command line arguments will be ignored
*/
public static void main(String[] args) {
try {
System.setProperty("java.awt.headless", "false");
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception | Error e) { System.out.printf("%s: %s\n", "Error setting system look and feel", e); }
Tetris.init();
}
}

View File

@@ -2,24 +2,26 @@ package eu.midnightdust.yaytris;
import eu.midnightdust.yaytris.util.Difficulty; import eu.midnightdust.yaytris.util.Difficulty;
import eu.midnightdust.yaytris.util.Language; import eu.midnightdust.yaytris.util.Language;
import eu.midnightdust.yaytris.util.NightJson; import eu.midnightdust.yaytris.util.json.Comment;
import eu.midnightdust.yaytris.util.json.NightJson;
import java.util.Arrays; import java.util.Arrays;
@SuppressWarnings("unused") // Comments are unused in code, but are added to the JSON file for readability
public class Settings { public class Settings {
private static final NightJson json = new NightJson(Settings.class, "tetris_settings.json5"); private static final NightJson json = new NightJson(Settings.class, "tetris_settings.json5");
public static NightJson.Comment c1 = new NightJson.Comment("Volume of theme music (0-100)"); public static Comment c1 = new Comment("Volume of theme music (0-100)");
public static int musicVolume = 100; public static int musicVolume = 100;
public static NightJson.Comment c2 = new NightJson.Comment("Volume of sound effects (0-100)"); public static Comment c2 = new Comment("Volume of sound effects (0-100)");
public static int soundVolume = 100; public static int soundVolume = 100;
public static NightJson.Comment c3 = new NightJson.Comment("Amount the user interface should be scaled"); public static Comment c3 = new Comment("Amount the user interface should be scaled");
public static float guiScale = 3.f; public static float guiScale = 3.f;
public static NightJson.Comment c4 = new NightJson.Comment("Whether speed should scale with level (true/false)"); public static Comment c4 = new Comment("Whether speed should scale with level (true/false)");
public static boolean shouldScaleSpeed = true; public static boolean shouldScaleSpeed = true;
public static NightJson.Comment c5 = new NightJson.Comment("One of %s", Arrays.toString(Difficulty.values())); public static Comment c5 = new Comment("One of %s", Arrays.toString(Difficulty.values()));
public static Difficulty difficulty = Difficulty.NORMAL; public static Difficulty difficulty = Difficulty.NORMAL;
public static NightJson.Comment c6 = new NightJson.Comment("One of %s", Arrays.toString(Language.values())); public static Comment c6 = new Comment("One of %s", Arrays.toString(Language.values()));
public static Language language = Language.ENGLISH; public static Language language = Language.ENGLISH;
public static void load() { public static void load() {

View File

@@ -1,12 +1,12 @@
package eu.midnightdust.yaytris; package eu.midnightdust.yaytris;
import eu.midnightdust.yaytris.game.Space; import eu.midnightdust.yaytris.game.Space;
import eu.midnightdust.yaytris.game.Tetromino;
import eu.midnightdust.yaytris.ui.ScoreMenu; import eu.midnightdust.yaytris.ui.ScoreMenu;
import eu.midnightdust.yaytris.ui.TetrisUI; import eu.midnightdust.yaytris.ui.TetrisUI;
import eu.midnightdust.yaytris.util.GravityTimerTask; import eu.midnightdust.yaytris.util.GravityTimerTask;
import eu.midnightdust.yaytris.util.sound.SoundUtil; import eu.midnightdust.yaytris.util.sound.SoundUtil;
import javax.swing.*;
import java.time.LocalTime; import java.time.LocalTime;
import java.util.Random; import java.util.Random;
import java.util.Timer; import java.util.Timer;
@@ -20,30 +20,19 @@ public class Tetris {
private static TimerTask gravityTask; private static TimerTask gravityTask;
private static LocalTime startTime; private static LocalTime startTime;
/** public static void init() {
* The application's entry point.
* Initializes the UI library to match the system style.
* Also loads saved settings, translations, and highscores from JSON.
*
* @param args command line arguments will be ignored
*/
public static void main(String[] args) {
try {
System.setProperty("java.awt.headless", "false");
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception | Error e) { System.out.printf("%s: %s\n", "Error setting system look and feel", e); }
Settings.load(); Settings.load();
Translation.load(Settings.language.locale); Translation.load(Settings.language.locale);
HighScores.load(); HighScores.load();
timer = new Timer("Tetris falling pieces"); Tetris.timer = new Timer("Tetris falling pieces");
space = new Space(); Tetris.space = new Space();
ui = new TetrisUI(); Tetris.ui = new TetrisUI();
} }
/** /**
* Get the active game space * Get the active game space
* *
* @see Space * @see Space
*/ */
public static Space getSpace() { public static Space getSpace() {
return space; return space;
@@ -52,7 +41,7 @@ public class Tetris {
/** /**
* Get the ui instance * Get the ui instance
* *
* @see TetrisUI * @see TetrisUI
*/ */
public static TetrisUI getUi() { public static TetrisUI getUi() {
return ui; return ui;
@@ -61,7 +50,7 @@ public class Tetris {
/** /**
* Resets the game space, preparing it for a new game. * Resets the game space, preparing it for a new game.
* *
* @see Space * @see Space
*/ */
public static void resetSpace() { public static void resetSpace() {
SoundUtil.stopMusic("/music/theme.wav"); SoundUtil.stopMusic("/music/theme.wav");
@@ -72,9 +61,9 @@ public class Tetris {
/** /**
* Starts a new game of Tetris :D * Starts a new game of Tetris :D
* This involves starting our gravity task, playing music and spawning the first {@link eu.midnightdust.yaytris.game.Tetromino} * This involves starting our gravity task, playing music and spawning the first {@link Tetromino}
* *
* @see Space#spawnTetromino() * @see Space#spawnTetromino()
*/ */
public static void startGame() { public static void startGame() {
SoundUtil.playMusic("/music/theme.wav", true); SoundUtil.playMusic("/music/theme.wav", true);
@@ -88,8 +77,8 @@ public class Tetris {
* Stops the current game. * Stops the current game.
* Disables falling, fades out music and handles saving of high scores. * Disables falling, fades out music and handles saving of high scores.
* *
* @see ScoreMenu * @see ScoreMenu
* @see HighScores * @see HighScores
*/ */
public static void stopGame() { public static void stopGame() {
SoundUtil.stopMusic("/music/theme.wav"); SoundUtil.stopMusic("/music/theme.wav");
@@ -97,44 +86,46 @@ public class Tetris {
timer.purge(); timer.purge();
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).gameOver(); if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).gameOver();
ui.transferFocus(); ui.transferFocus();
if (HighScores.scores.values().stream().noneMatch(hs -> hs > space.getScore())) ui.showHighscoreDialog(space.getScore()); if (HighScores.diffToMap(Settings.difficulty).values().stream().noneMatch(hs -> hs > space.getScore()))
ui.showHighscoreDialog(space.getScore());
} }
/** /**
* Updates the displayed score * Updates the displayed score
* *
* @param score the new score * @param score the new score
* @see ScoreMenu * @see ScoreMenu
*/ */
public static void updateScore(int score) { public static void updateScore(int score) {
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateScore(score); if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateScore(score);
updateLevel(score); updateLevel(score);
} }
/** /**
* Updates the elapsed time * Updates the elapsed time
* *
* @see ScoreMenu * @see ScoreMenu
*/ */
public static void updateTime() { public static void updateTime() {
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateTime(startTime); if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateTime(startTime);
} }
/** /**
* Updates the displayed level * Updates the displayed level
* *
* @param score the new score, from which the level will be calculated * @param score the new score, from which the level will be calculated
* @see ScoreMenu * @see ScoreMenu
*/ */
public static void updateLevel(int score) { public static void updateLevel(int score) {
int newLevel = Math.max(0, (int) (score / 1000f)); int newLevel = Math.max(0, (int) (score / 1400f));
if (newLevel != space.level) { if (newLevel != space.level) {
if (gravityTask != null && Settings.shouldScaleSpeed) { if (gravityTask != null && Settings.shouldScaleSpeed) {
gravityTask.cancel(); gravityTask.cancel();
gravityTask = new GravityTimerTask(); gravityTask = new GravityTimerTask();
timer.scheduleAtFixedRate(gravityTask, 0, Math.max(10, Settings.difficulty.getTimerPeriod() - (Settings.difficulty.getTimerPeriod() / 8) * newLevel)); timer.scheduleAtFixedRate(gravityTask, 0, Math.max(10, Settings.difficulty.getTimerPeriod() - (Settings.difficulty.getTimerPeriod() / 16) * newLevel));
} }
space.level = newLevel; space.level = newLevel;
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateLevel(newLevel); if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateLevel(newLevel);
} }
} }
}
}

View File

@@ -1,6 +1,6 @@
package eu.midnightdust.yaytris; package eu.midnightdust.yaytris;
import eu.midnightdust.yaytris.util.NightJson; import eu.midnightdust.yaytris.util.json.NightJson;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;

View File

@@ -19,7 +19,7 @@ public class Space {
* Space saves the current state of the game map * Space saves the current state of the game map
*/ */
public Space() { public Space() {
gameMap = new Color[20][12]; gameMap = new Color[19][10];
nextShape = generateNextShape(); nextShape = generateNextShape();
score = 0; score = 0;
} }

View File

@@ -1,16 +1,22 @@
package eu.midnightdust.yaytris.ui; package eu.midnightdust.yaytris.ui;
import eu.midnightdust.yaytris.util.CatppuccinColor;
import javax.swing.*; import javax.swing.*;
import java.awt.*; import java.awt.*;
import static eu.midnightdust.yaytris.ui.TetrisUI.scale; import static eu.midnightdust.yaytris.ui.TetrisUI.scale;
import static eu.midnightdust.yaytris.ui.TetrisUI.setFontScale;
public class AbstractMenu extends JPanel { public class AbstractMenu extends JPanel {
@Override @Override
public Component add(Component comp) { public Component add(Component comp) {
comp.setBounds(scale(60), scale(20+getSpacing()*this.getComponentCount()), scale(100), scale(20)); comp.setBounds(scale(60), scale(20+getSpacing()*this.getComponentCount()), scale(100), scale(20));
if (comp instanceof JComponent) setFontScale((JComponent) comp); if (comp instanceof JLabel) {
comp.setForeground(CatppuccinColor.TEXT.getColor());
}
if (comp instanceof JComponent) {
((JComponent) comp).setOpaque(false);
}
return super.add(comp); return super.add(comp);
} }

View File

@@ -1,6 +1,7 @@
package eu.midnightdust.yaytris.ui; package eu.midnightdust.yaytris.ui;
import eu.midnightdust.yaytris.HighScores; import eu.midnightdust.yaytris.HighScores;
import eu.midnightdust.yaytris.Settings;
import eu.midnightdust.yaytris.util.CatppuccinColor; import eu.midnightdust.yaytris.util.CatppuccinColor;
import javax.swing.*; import javax.swing.*;
@@ -8,6 +9,7 @@ import javax.swing.border.LineBorder;
import java.awt.*; import java.awt.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import static eu.midnightdust.yaytris.Translation.t; import static eu.midnightdust.yaytris.Translation.t;
import static eu.midnightdust.yaytris.ui.TetrisUI.scale; import static eu.midnightdust.yaytris.ui.TetrisUI.scale;
@@ -20,20 +22,23 @@ public class HighScoreMenu extends JPanel {
this.setBounds(x, y, width, height); this.setBounds(x, y, width, height);
this.setLayout(null); this.setLayout(null);
this.add(new JLabel(t("highscores.title"))); this.add(new JLabel(t("highscores.title", Settings.difficulty)));
List<String> highscores = new ArrayList<>(); List<String> highscores = new ArrayList<>();
for (String key : HighScores.scores.keySet()) { Map<String, Integer> scores = HighScores.diffToMap(Settings.difficulty);
highscores.add(String.format("%s %s", HighScores.scores.get(key), key)); for (String key : scores.keySet()) {
highscores.add(String.format("%s %s", scores.get(key), key));
} }
highscores.sort((s1, s2) -> Integer.compare(Integer.parseInt(s2.split("")[0].replace(" ", "")), Integer.parseInt(s1.split("")[0].replace(" ", "")))); highscores.sort((s1, s2) -> Integer.compare(Integer.parseInt(s2.split("")[0].replace(" ", "")), Integer.parseInt(s1.split("")[0].replace(" ", ""))));
JList<String> highscoreList = new JList<>(highscores.toArray(String[]::new)); JList<String> highscoreList = new JList<>(highscores.toArray(String[]::new));
highscoreList.setBackground(CatppuccinColor.BASE.getColor()); highscoreList.setBackground(CatppuccinColor.BASE.getColor());
highscoreList.setForeground(CatppuccinColor.TEXT.getColor());
highscoreList.setSelectionForeground(CatppuccinColor.CRUST.getColor()); highscoreList.setSelectionForeground(CatppuccinColor.CRUST.getColor());
JScrollPane highscoreScrollPane = new JScrollPane(highscoreList); JScrollPane highscoreScrollPane = new JScrollPane(highscoreList);
highscoreScrollPane.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), 3, true)); highscoreScrollPane.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), 3, true));
this.add(highscoreScrollPane); this.add(highscoreScrollPane);
highscoreScrollPane.setBounds(scale(60), scale(43), scale(100), scale(80)); highscoreScrollPane.setBounds(scale(60), scale(43), scale(100), scale(80));
highscoreList.setBounds(scale(60), scale(43), scale(100), scale(80));
JButton backButton = new JButton(t("ui.back")); JButton backButton = new JButton(t("ui.back"));
backButton.addActionListener(ui::openMainMenu); backButton.addActionListener(ui::openMainMenu);
@@ -45,6 +50,10 @@ public class HighScoreMenu extends JPanel {
public Component add(Component comp) { public Component add(Component comp) {
if (comp instanceof JLabel) { if (comp instanceof JLabel) {
comp.setBounds(scale(60), scale(30), scale(100), scale(7)); comp.setBounds(scale(60), scale(30), scale(100), scale(7));
comp.setForeground(CatppuccinColor.TEXT.getColor());
}
if (comp instanceof JComponent) {
((JComponent) comp).setOpaque(false);
} }
return super.add(comp); return super.add(comp);
} }

View File

@@ -2,6 +2,7 @@ package eu.midnightdust.yaytris.ui;
import eu.midnightdust.yaytris.Settings; import eu.midnightdust.yaytris.Settings;
import eu.midnightdust.yaytris.Translation; import eu.midnightdust.yaytris.Translation;
import eu.midnightdust.yaytris.util.CatppuccinColor;
import eu.midnightdust.yaytris.util.Difficulty; import eu.midnightdust.yaytris.util.Difficulty;
import eu.midnightdust.yaytris.util.Language; import eu.midnightdust.yaytris.util.Language;
@@ -10,7 +11,6 @@ import java.awt.*;
import static eu.midnightdust.yaytris.Translation.t; import static eu.midnightdust.yaytris.Translation.t;
import static eu.midnightdust.yaytris.ui.TetrisUI.scale; import static eu.midnightdust.yaytris.ui.TetrisUI.scale;
import static eu.midnightdust.yaytris.ui.TetrisUI.setFontScale;
public class SettingsMenu extends JPanel { public class SettingsMenu extends JPanel {
final TetrisUI ui; final TetrisUI ui;
@@ -87,9 +87,10 @@ public class SettingsMenu extends JPanel {
comp.setBounds(scale(60), scale(20+17*this.getComponentCount()-labelAmount*10), scale(100), scale(20)); comp.setBounds(scale(60), scale(20+17*this.getComponentCount()-labelAmount*10), scale(100), scale(20));
if (comp instanceof JLabel) { if (comp instanceof JLabel) {
comp.setBounds(scale(60), scale(20+17*(this.getComponentCount())-labelAmount*10), scale(100), scale(7)); comp.setBounds(scale(60), scale(20+17*(this.getComponentCount())-labelAmount*10), scale(100), scale(7));
comp.setForeground(CatppuccinColor.TEXT.getColor());
labelAmount++; labelAmount++;
} }
if (comp instanceof JComponent) setFontScale((JComponent) comp); if (comp instanceof JComponent) ((JComponent) comp).setOpaque(false);
return super.add(comp); return super.add(comp);
} }
} }

View File

@@ -12,6 +12,7 @@ import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.awt.event.KeyListener; import java.awt.event.KeyListener;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import static eu.midnightdust.yaytris.Settings.guiScale; import static eu.midnightdust.yaytris.Settings.guiScale;
import static eu.midnightdust.yaytris.Translation.t; import static eu.midnightdust.yaytris.Translation.t;
@@ -20,6 +21,7 @@ public class TetrisUI extends JFrame implements KeyListener {
JLabel titleLabel; JLabel titleLabel;
GameCanvas gamePanel; GameCanvas gamePanel;
JPanel menuPanel; JPanel menuPanel;
Image titleImage;
/** /**
* Initialize the main Tetris GUI based on Swing * Initialize the main Tetris GUI based on Swing
@@ -27,22 +29,21 @@ public class TetrisUI extends JFrame implements KeyListener {
public TetrisUI() { public TetrisUI() {
this.setLayout(null); this.setLayout(null);
this.setTitle("Tetris"); this.setTitle("Tetris");
this.setSize((int) (400 * guiScale), (int) (300 * guiScale)); this.setSize((int) (400 * guiScale), (int) (320 * guiScale));
this.setResizable(false); this.setResizable(false);
this.getContentPane().setBackground(CatppuccinColor.MANTLE.getColor()); this.getContentPane().setBackground(CatppuccinColor.MANTLE.getColor());
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setWindowPosition(this, 0); this.setLocationRelativeTo(null);
titleLabel = new JLabel("Tetris"); titleLabel = new JLabel("Tetris");
titleLabel.setForeground(Color.WHITE); titleLabel.setForeground(Color.WHITE);
Image titleImage; try (InputStream stream = this.getClass().getResourceAsStream("/textures/logo.png")) {
try { assert stream != null;
titleImage = ImageIO.read(this.getClass().getResourceAsStream("/textures/logo.png")); titleImage = ImageIO.read(stream);
} catch (IOException | NullPointerException ex) { } catch (IOException | NullPointerException ex) {
throw new RuntimeException(ex); throw new RuntimeException(ex);
} }
titleLabel = new JLabel(); titleLabel = new JLabel();
titleLabel.setIcon(new ImageIcon(new ImageIcon(titleImage).getImage().getScaledInstance(scale(110), scale(30), Image.SCALE_DEFAULT)));
this.add(titleLabel); this.add(titleLabel);
gamePanel = new GameCanvas(this); gamePanel = new GameCanvas(this);
@@ -61,12 +62,11 @@ public class TetrisUI extends JFrame implements KeyListener {
* Resize all elements to match the current GUI scale. * Resize all elements to match the current GUI scale.
*/ */
private void rescale() { private void rescale() {
this.setSize((int) (400 * guiScale), (int) (300 * guiScale)); this.setSize((int) (400 * guiScale), (int) (320 * guiScale));
titleLabel.setBounds(scale(225), scale(7), scale(110), scale(30)); titleLabel.setBounds(scale(225), scale(7), scale(110), scale(30));
gamePanel.setBounds(scale(10), scale(10), scale(150), scale(256)); titleLabel.setIcon(new ImageIcon(titleImage.getScaledInstance(scale(110), scale(30), Image.SCALE_DEFAULT)));
for (Component comp : this.getComponents()){ gamePanel.setBounds(scale(10), scale(10), scale(150), scale(282));
if (comp instanceof JComponent) setFontScale((JComponent) comp); this.setLocationRelativeTo(null);
}
} }
/** /**
@@ -77,9 +77,6 @@ public class TetrisUI extends JFrame implements KeyListener {
public static int scale(int bound) { public static int scale(int bound) {
return (int) (bound * guiScale); return (int) (bound * guiScale);
} }
public static void setFontScale(JComponent label) {
//if (label.getFont() != null) label.setFont(label.getFont().deriveFont((float) label.getFont().getSize() * guiScale));
}
public GameCanvas getGamePanel() { public GameCanvas getGamePanel() {
return gamePanel; return gamePanel;
@@ -106,11 +103,11 @@ public class TetrisUI extends JFrame implements KeyListener {
* *
* @param actionEvent unnecessary, but allows for more elegant lambda statements :) * @param actionEvent unnecessary, but allows for more elegant lambda statements :)
*/ */
public void openMainMenu(ActionEvent actionEvent) { public void openMainMenu(@SuppressWarnings("unused") ActionEvent actionEvent) {
if (this.menuPanel != null) this.remove(menuPanel); if (this.menuPanel != null) this.remove(menuPanel);
Tetris.resetSpace(); Tetris.resetSpace();
rescale(); rescale();
menuPanel = new MainMenu(scale(170), scale(40), scale(220), scale(226), this); menuPanel = new MainMenu(scale(170), scale(40), scale(220), scale(252), this);
menuPanel.setBackground(CatppuccinColor.BASE.getColor()); menuPanel.setBackground(CatppuccinColor.BASE.getColor());
menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2))); menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel); this.add(menuPanel);
@@ -124,9 +121,9 @@ public class TetrisUI extends JFrame implements KeyListener {
* *
* @param actionEvent unnecessary, but allows for more elegant lambda statements :) * @param actionEvent unnecessary, but allows for more elegant lambda statements :)
*/ */
public void openSettings(ActionEvent actionEvent) { public void openSettings(@SuppressWarnings("unused") ActionEvent actionEvent) {
if (this.menuPanel != null) this.remove(menuPanel); if (this.menuPanel != null) this.remove(menuPanel);
menuPanel = new SettingsMenu(scale(170), scale(40), scale(220), scale(226), this); menuPanel = new SettingsMenu(scale(170), scale(40), scale(220), scale(252), this);
menuPanel.setBackground(CatppuccinColor.BASE.getColor()); menuPanel.setBackground(CatppuccinColor.BASE.getColor());
menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2))); menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel); this.add(menuPanel);
@@ -138,9 +135,9 @@ public class TetrisUI extends JFrame implements KeyListener {
* *
* @param actionEvent unnecessary, but allows for more elegant lambda statements :) * @param actionEvent unnecessary, but allows for more elegant lambda statements :)
*/ */
public void openScoreMenu(ActionEvent actionEvent) { public void openScoreMenu(@SuppressWarnings("unused") ActionEvent actionEvent) {
if (this.menuPanel != null) this.remove(menuPanel); if (this.menuPanel != null) this.remove(menuPanel);
menuPanel = new ScoreMenu(scale(170), scale(40), scale(220), scale(226), this); menuPanel = new ScoreMenu(scale(170), scale(40), scale(220), scale(252), this);
menuPanel.setBackground(CatppuccinColor.BASE.getColor()); menuPanel.setBackground(CatppuccinColor.BASE.getColor());
menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2))); menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel); this.add(menuPanel);
@@ -152,9 +149,9 @@ public class TetrisUI extends JFrame implements KeyListener {
* *
* @param actionEvent unnecessary, but allows for more elegant lambda statements :) * @param actionEvent unnecessary, but allows for more elegant lambda statements :)
*/ */
public void openHighscores(ActionEvent actionEvent) { public void openHighscores(@SuppressWarnings("unused") ActionEvent actionEvent) {
if (this.menuPanel != null) this.remove(menuPanel); if (this.menuPanel != null) this.remove(menuPanel);
menuPanel = new HighScoreMenu(scale(170), scale(40), scale(220), scale(226), this); menuPanel = new HighScoreMenu(scale(170), scale(40), scale(220), scale(252), this);
menuPanel.setBackground(CatppuccinColor.BASE.getColor()); menuPanel.setBackground(CatppuccinColor.BASE.getColor());
menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2))); menuPanel.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel); this.add(menuPanel);
@@ -168,41 +165,7 @@ public class TetrisUI extends JFrame implements KeyListener {
*/ */
public void showHighscoreDialog(int score) { public void showHighscoreDialog(int score) {
String playerName = JOptionPane.showInputDialog(null, t("dialog.highscore.action"), t("dialog.highscore.title"), JOptionPane.PLAIN_MESSAGE); String playerName = JOptionPane.showInputDialog(null, t("dialog.highscore.action"), t("dialog.highscore.title"), JOptionPane.PLAIN_MESSAGE);
HighScores.addScore(playerName, score); if (playerName != null) HighScores.addScore(playerName, score);
}
/**
* Centers the game window on the given screen.
* Source: <a href="https://stackoverflow.com/a/19746437">Miss Chanandler Bong & Peter Szabo on StackOverflow</a>
*
* @param window the window to center
* @param screen the screen to center it on
*/
private void setWindowPosition(JFrame window, int screen) {
GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment();
GraphicsDevice[] allDevices = env.getScreenDevices();
int topLeftX, topLeftY, screenX, screenY, windowPosX, windowPosY;
if (screen < allDevices.length && screen > -1) {
topLeftX = allDevices[screen].getDefaultConfiguration().getBounds().x;
topLeftY = allDevices[screen].getDefaultConfiguration().getBounds().y;
screenX = allDevices[screen].getDefaultConfiguration().getBounds().width;
screenY = allDevices[screen].getDefaultConfiguration().getBounds().height;
}
else {
topLeftX = allDevices[0].getDefaultConfiguration().getBounds().x;
topLeftY = allDevices[0].getDefaultConfiguration().getBounds().y;
screenX = allDevices[0].getDefaultConfiguration().getBounds().width;
screenY = allDevices[0].getDefaultConfiguration().getBounds().height;
}
windowPosX = ((screenX - window.getWidth()) / 2) + topLeftX;
windowPosY = ((screenY - window.getHeight()) / 2) + topLeftY;
window.setLocation(windowPosX, windowPosY);
} }
/** /**

View File

@@ -6,7 +6,7 @@ import java.awt.*;
* Color scheme based on the <a href="https://github.com/catppuccin/catppuccin">Catppuccin Mocha</a> color palette * Color scheme based on the <a href="https://github.com/catppuccin/catppuccin">Catppuccin Mocha</a> color palette
*/ */
public enum CatppuccinColor { public enum CatppuccinColor {
CRUST(0x11111b), MANTLE(0x181825), BASE(0x1e1e2e), SURFACE0(0x313244); TEXT(0xcdd6f4), CRUST(0x11111b), MANTLE(0x181825), BASE(0x1e1e2e), SURFACE0(0x313244);
final Color color; final Color color;
CatppuccinColor(int rgb) { CatppuccinColor(int rgb) {

View File

@@ -1,9 +1,6 @@
package eu.midnightdust.yaytris.util; package eu.midnightdust.yaytris.util;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -17,21 +14,4 @@ public class FileUtil {
throw new RuntimeException(ex); throw new RuntimeException(ex);
} }
} }
public static AudioInputStream loadAudio(String location) {
try (InputStream fileStream = FileUtil.class.getResourceAsStream(location)) {
assert fileStream != null;
return AudioSystem.getAudioInputStream(fileStream);
} catch (IOException | NullPointerException | UnsupportedAudioFileException ex) {
throw new RuntimeException(ex);
}
}
public static InputStream getFileStream(String location) {
try (InputStream fileStream = FileUtil.class.getResourceAsStream(location)) {
return fileStream;
} catch (IOException | NullPointerException ex) {
throw new RuntimeException(ex);
}
}
} }

View File

@@ -0,0 +1,19 @@
package eu.midnightdust.yaytris.util.json;
/**
* Add comments to your json files.
* If you decide to use this, it's best to save with the .json5 extension, as regular json does not officially support comments.
*/
public class Comment {
final String commentString;
/**
* Add a comment to spice-up the json file :)
*
* @param commentString the string you want to write as a comment
* @param args optional formatting arguments, calls {@link String#format(String, Object...)}
*/
public Comment(String commentString, Object... args) {
this.commentString = String.format(commentString, args);
}
}

View File

@@ -1,4 +1,4 @@
package eu.midnightdust.yaytris.util; package eu.midnightdust.yaytris.util.json;
import java.io.File; import java.io.File;
import java.io.FileWriter; import java.io.FileWriter;
@@ -12,12 +12,12 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* NightJson v0.2 by Martin Prokoph * NightJson v0.3 by Martin Prokoph
* Extremely lightweight (and incomplete) JSON library * Extremely lightweight JSON library
* Concept inspired by GSON * Concept inspired by GSON
*/ */
public class NightJson { public class NightJson {
private static final String KEY_PATTERN = "\"(-?[A-Za-z-_.]*)\":"; private static final String KEY_PATTERN = "\"(.*)\":";
Class<?> jsonClass; Class<?> jsonClass;
Field jsonMap; Field jsonMap;
String fileLocation; String fileLocation;
@@ -39,28 +39,11 @@ public class NightJson {
/** /**
* Convert the current state of the java json class to actual json and save it to disk. * Convert the current state of the java json class to actual json and save it to disk.
*/ */
@SuppressWarnings("unchecked")
public void writeJson() { public void writeJson() {
if (fileLocation == null) return; if (fileLocation == null) return;
try { try {
FileWriter jsonFile = new FileWriter(fileLocation); FileWriter jsonFile = new FileWriter(fileLocation);
writeJsonToFile(jsonFile);
jsonFile.write("{\n");
Iterator<Field> it = Arrays.stream(jsonClass.getFields()).iterator();
while (it.hasNext()) {
Field field = it.next();
if (field != jsonMap) writeElement(jsonFile, field.get(null), field.getType(), field.getName(), it.hasNext());
}
if (jsonMap != null) {
Iterator<String> mapIt = ((Map<String,?>)jsonMap.get(null)).keySet().iterator();
while (mapIt.hasNext()) {
String key = mapIt.next();
Object value = jsonMap.get(key);
writeElement(jsonFile, value, value.getClass(), key, mapIt.hasNext());
}
}
jsonFile.write("}");
jsonFile.close();
} catch (IOException | IllegalAccessException e) { } catch (IOException | IllegalAccessException e) {
System.out.println("Oh no! An Error occurred whilst writing the JSON file :("); System.out.println("Oh no! An Error occurred whilst writing the JSON file :(");
//noinspection CallToPrintStackTrace //noinspection CallToPrintStackTrace
@@ -68,10 +51,30 @@ public class NightJson {
} }
} }
@SuppressWarnings("unchecked")
private void writeJsonToFile(FileWriter jsonFile) throws IOException, IllegalAccessException {
jsonFile.write("{\n");
Iterator<Field> it = Arrays.stream(jsonClass.getFields()).iterator();
while (it.hasNext()) {
Field field = it.next();
if (field != jsonMap) writeElement(jsonFile, field.get(null), field.getType(), field.getName(), it.hasNext());
}
if (jsonMap != null) {
Iterator<String> mapIt = ((Map<String,?>)jsonMap.get(null)).keySet().iterator();
while (mapIt.hasNext()) {
String key = mapIt.next();
Object value = jsonMap.get(key);
writeElement(jsonFile, value, value.getClass(), key, mapIt.hasNext());
}
}
jsonFile.write("}");
jsonFile.close();
}
/** /**
* Write the desired element into the file. * Write the desired element into the file.
*/ */
private void writeElement(FileWriter jsonFile, Object value, Class<?> type, String name, boolean hasNext) throws IOException, IllegalAccessException { private void writeElement(FileWriter jsonFile, Object value, Class<?> type, String name, boolean hasNext) throws IOException {
jsonFile.write("\t"); jsonFile.write("\t");
if (type == Comment.class) { if (type == Comment.class) {
jsonFile.write(String.format("// %s\n", ((Comment) value).commentString)); jsonFile.write(String.format("// %s\n", ((Comment) value).commentString));
@@ -90,7 +93,7 @@ public class NightJson {
StringBuilder mapPairs = new StringBuilder(); StringBuilder mapPairs = new StringBuilder();
Map<?, ?> map = (Map<?, ?>) value; Map<?, ?> map = (Map<?, ?>) value;
Iterator<?> it = map.keySet().iterator(); Iterator<?> it = map.keySet().iterator();
if (it.hasNext()) mapPairs.append("{"); mapPairs.append(it.hasNext() ? "{" : "{}");
while (it.hasNext()) { while (it.hasNext()) {
Object key = it.next(); Object key = it.next();
Object val = map.get(key); Object val = map.get(key);
@@ -120,23 +123,9 @@ public class NightJson {
/** /**
* Read the json file from disk and overwrite the json class's field values. * Read the json file from disk and overwrite the json class's field values.
*/ */
@SuppressWarnings("unchecked")
public void readJsonFromString(String jsonString) { public void readJsonFromString(String jsonString) {
try { try {
Map<String, Object> asMap = jsonToMap( readJsonString(jsonString);
jsonString.replaceAll("(//)+.*\n", ""), // Replace comment lines (Json5)
(key) -> getField(key).isPresent() ? getField(key).get().getType() : String.class); // Determine data type
for (String key : asMap.keySet()) {
Object value = asMap.get(key);
Optional<Field> field = getField(key);
if (field.isPresent()) {
field.get().set(null, value);
}
else if (jsonMap != null) {
((Map<String, Object>)jsonMap.get(null)).put(key, value);
}
}
} catch (IllegalAccessException | NoSuchElementException | ClassCastException e) { } catch (IllegalAccessException | NoSuchElementException | ClassCastException e) {
System.out.println("Oh no! An Error occurred whilst reading the JSON file :("); System.out.println("Oh no! An Error occurred whilst reading the JSON file :(");
//noinspection CallToPrintStackTrace //noinspection CallToPrintStackTrace
@@ -144,6 +133,24 @@ public class NightJson {
} }
} }
@SuppressWarnings("unchecked")
private void readJsonString(String jsonString) throws IllegalAccessException {
Map<String, Object> asMap = jsonToMap(
jsonString.replaceAll("(//)+.*\n", ""), // Replace comment lines (Json5)
(key) -> getField(key).isPresent() ? getField(key).get().getType() : String.class); // Determine data type
for (String key : asMap.keySet()) {
Object value = asMap.get(key);
Optional<Field> field = getField(key);
if (field.isPresent()) {
field.get().set(null, value);
}
else if (jsonMap != null) {
((Map<String, Object>)jsonMap.get(null)).put(key, value);
}
}
}
/** /**
* Read the json file as key-value pairs and save it as a map. * Read the json file as key-value pairs and save it as a map.
*/ */
@@ -168,21 +175,8 @@ public class NightJson {
private Object getValue(String s, String key, Function<String, Class<?>> keyToType, Iterator<String> pairIterator) { private Object getValue(String s, String key, Function<String, Class<?>> keyToType, Iterator<String> pairIterator) {
String val = s.split(KEY_PATTERN, 2)[1]; String val = s.split(KEY_PATTERN, 2)[1];
if (s.contains("{")) { // Handle maps recursively if (s.contains("{")) {
StringBuilder submapString = new StringBuilder(); return readJsonMap(key, pairIterator, val);
int level = charAmount(s, '{');
submapString.append(val);
if (pairIterator.hasNext()) submapString.append(",");
while (pairIterator.hasNext()) {
String next = pairIterator.next();
submapString.append(next);
level += charAmount(next, '{');
level -= charAmount(next, '}');
if (level <= 0) break;
if (pairIterator.hasNext()) submapString.append(",");
}
Optional<Field> field = getField(key);
return jsonToMap(String.valueOf(submapString), k -> field.isPresent() ? getTypeArgument(field.get(), 1) : String.class);
} }
else { else {
while (val.startsWith(" ")) val = val.substring(1); while (val.startsWith(" ")) val = val.substring(1);
@@ -193,6 +187,27 @@ public class NightJson {
} }
} }
/**
* Handle maps recursively
*/
private Map<String, Object> readJsonMap(String key, Iterator<String> pairIterator, String val) {
StringBuilder submapString = new StringBuilder();
String next = val;
int level = 0;
while (next != null) {
submapString.append(next);
level += charAmount(next, '{');
level -= charAmount(next, '}');
if (level <= 0) break;
if (!pairIterator.hasNext()) {
submapString.append(",");
next = pairIterator.next();
} else next = null;
}
Optional<Field> field = getField(key);
return jsonToMap(String.valueOf(submapString), k -> field.isPresent() ? getTypeArgument(field.get(), 1) : String.class);
}
/** /**
* Count the amount of appearances of a char in a string. * Count the amount of appearances of a char in a string.
* *
@@ -249,20 +264,4 @@ public class NightJson {
return Optional.empty(); return Optional.empty();
} }
} }
/**
* Add comments to your json files.
* If you decide to use this, it's best to save with the .json5 extension, as regular json does not officially support comments.
*/
public static class Comment {
final String commentString;
/**
* Add a comment to spice-up the json file :)
* @param commentString the string you want to write as a comment
* @param args optional formatting arguments, calls {@link String#format(String, Object...)}
*/
public Comment(String commentString, Object... args) {
this.commentString = String.format(commentString, args);
}
}
} }

View File

@@ -65,6 +65,8 @@ public class MusicThread extends Thread {
sourceDataLine.close(); sourceDataLine.close();
} catch (LineUnavailableException | IOException | UnsupportedAudioFileException e) { } catch (LineUnavailableException | IOException | UnsupportedAudioFileException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} catch (IllegalArgumentException ignored) {
// Happens when no audio device is connected
} }
} }
} }

View File

@@ -52,6 +52,8 @@ public class SoundUtil {
setVolume(audioClip, Settings.soundVolume); setVolume(audioClip, Settings.soundVolume);
} catch (LineUnavailableException | IOException | UnsupportedAudioFileException e) { } catch (LineUnavailableException | IOException | UnsupportedAudioFileException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} catch (IllegalArgumentException ignored) {
// Happens when no audio device is connected
} }
} }

Binary file not shown.

View File

@@ -5,7 +5,7 @@
"game.over": "Game over :(", "game.over": "Game over :(",
"game.score": "Score: %s", "game.score": "Score: %s",
"game.time": "Zeit: %s", "game.time": "Zeit: %s",
"highscores.title": "Highscores:", "highscores.title": "Highscores (%s):",
"menu.exit": "Spiel verlassen", "menu.exit": "Spiel verlassen",
"menu.highscores": "Highscores", "menu.highscores": "Highscores",
"menu.settings": "Einstellungen", "menu.settings": "Einstellungen",

View File

@@ -5,7 +5,7 @@
"game.over": "Game over :(", "game.over": "Game over :(",
"game.score": "Score: %s", "game.score": "Score: %s",
"game.time": "Time: %s", "game.time": "Time: %s",
"highscores.title": "Highscores:", "highscores.title": "Highscores (%s):",
"menu.exit": "Exit Game", "menu.exit": "Exit Game",
"menu.highscores": "Highscores", "menu.highscores": "Highscores",
"menu.settings": "Settings", "menu.settings": "Settings",

View File

@@ -1,5 +1,11 @@
{ {
"scores": { // Highscores for each difficulty
"MartinProkoph": 8040 "Noob": {},
} "Easy": {},
"Normal": {
"Martin": 10593
},
"Hard": {},
"Extreme": {},
"WTF": {}
} }

View File

@@ -1,10 +1,10 @@
{ {
// Volume of theme music (0-100) // Volume of theme music (0-100)
"musicVolume": 30, "musicVolume": 100,
// Volume of sound effects (0-100) // Volume of sound effects (0-100)
"soundVolume": 100, "soundVolume": 100,
// Amount the user interface should be scaled // Amount the user interface should be scaled
"guiScale": 5.7, "guiScale": 2.00,
// Whether speed should scale with level (true/false) // Whether speed should scale with level (true/false)
"shouldScaleSpeed": true, "shouldScaleSpeed": true,
// One of [Noob, Easy, Normal, Hard, Extreme, WTF] // One of [Noob, Easy, Normal, Hard, Extreme, WTF]