Compare commits

...

5 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
12 changed files with 145 additions and 66 deletions

View File

@@ -4,15 +4,44 @@ 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?
![Screenshot des Tetris-Spiels](assets/ingame.png) ![Screenshot des Tetris-Spiels](assets/ingame.png)
## Im Falle einer IllegalArgumentException
Eclipse ist unfähig Java-Konventionen zu befolgen und fügt den resources-Ordner nicht automatisch zum classpath hinzu.
Um das zu beheben, gehen Sie in die Projekteinstellungen -> Java Build Path -> Source und fügen Sie den resources-Ordner manuell hinzu.
## 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.Difficulty;
import eu.midnightdust.yaytris.util.json.Comment;
import eu.midnightdust.yaytris.util.json.NightJson; 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

@@ -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,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,11 +22,12 @@ 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));

View File

@@ -64,7 +64,7 @@ public class TetrisUI extends JFrame implements KeyListener {
private void rescale() { private void rescale() {
this.setSize((int) (400 * guiScale), (int) (320 * 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));
titleLabel.setIcon(new ImageIcon(new ImageIcon(titleImage).getImage().getScaledInstance(scale(110), scale(30), Image.SCALE_DEFAULT))); titleLabel.setIcon(new ImageIcon(titleImage.getScaledInstance(scale(110), scale(30), Image.SCALE_DEFAULT)));
gamePanel.setBounds(scale(10), scale(10), scale(150), scale(282)); gamePanel.setBounds(scale(10), scale(10), scale(150), scale(282));
this.setLocationRelativeTo(null); this.setLocationRelativeTo(null);
} }
@@ -165,7 +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);
} }
/** /**

View File

@@ -17,7 +17,7 @@ import java.util.regex.Pattern;
* 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;
@@ -175,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);
@@ -200,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.
* *

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": {}
} }