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.
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.
Gradle ist als Build-Tool eingerichtet, um elegant Jar-Dateien zu bauen, dabei aber völlig optional.
Können Sie meinen Highscore schlagen?
![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
»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;
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.Map;
@SuppressWarnings("unused") // Comments are unused in code, but are added to the JSON file for readability
public class HighScores {
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
@@ -16,10 +25,21 @@ public class HighScores {
* @param score the score to save
*/
public static void addScore(String playerName, int score) {
scores.put(playerName, score);
diffToMap(Settings.difficulty).put(playerName, score);
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() {
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;
import eu.midnightdust.yaytris.game.Space;
import eu.midnightdust.yaytris.game.Tetromino;
import eu.midnightdust.yaytris.ui.ScoreMenu;
import eu.midnightdust.yaytris.ui.TetrisUI;
import eu.midnightdust.yaytris.util.GravityTimerTask;
import eu.midnightdust.yaytris.util.sound.SoundUtil;
import javax.swing.*;
import java.time.LocalTime;
import java.util.Random;
import java.util.Timer;
@@ -20,30 +20,19 @@ public class Tetris {
private static TimerTask gravityTask;
private static LocalTime startTime;
/**
* 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); }
public static void init() {
Settings.load();
Translation.load(Settings.language.locale);
HighScores.load();
timer = new Timer("Tetris falling pieces");
space = new Space();
ui = new TetrisUI();
Tetris.timer = new Timer("Tetris falling pieces");
Tetris.space = new Space();
Tetris.ui = new TetrisUI();
}
/**
* Get the active game space
*
* @see Space
* @see Space
*/
public static Space getSpace() {
return space;
@@ -52,7 +41,7 @@ public class Tetris {
/**
* Get the ui instance
*
* @see TetrisUI
* @see TetrisUI
*/
public static TetrisUI getUi() {
return ui;
@@ -61,7 +50,7 @@ public class Tetris {
/**
* Resets the game space, preparing it for a new game.
*
* @see Space
* @see Space
*/
public static void resetSpace() {
SoundUtil.stopMusic("/music/theme.wav");
@@ -72,9 +61,9 @@ public class Tetris {
/**
* 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() {
SoundUtil.playMusic("/music/theme.wav", true);
@@ -88,8 +77,8 @@ public class Tetris {
* Stops the current game.
* Disables falling, fades out music and handles saving of high scores.
*
* @see ScoreMenu
* @see HighScores
* @see ScoreMenu
* @see HighScores
*/
public static void stopGame() {
SoundUtil.stopMusic("/music/theme.wav");
@@ -97,44 +86,46 @@ public class Tetris {
timer.purge();
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).gameOver();
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
*
* @param score the new score
* @see ScoreMenu
* @param score the new score
* @see ScoreMenu
*/
public static void updateScore(int score) {
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateScore(score);
updateLevel(score);
}
/**
* Updates the elapsed time
*
* @see ScoreMenu
* @see ScoreMenu
*/
public static void updateTime() {
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateTime(startTime);
}
/**
* Updates the displayed level
*
* @param score the new score, from which the level will be calculated
* @see ScoreMenu
* @param score the new score, from which the level will be calculated
* @see ScoreMenu
*/
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 (gravityTask != null && Settings.shouldScaleSpeed) {
gravityTask.cancel();
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;
if (ui.getMenuPanel() instanceof ScoreMenu) ((ScoreMenu) ui.getMenuPanel()).updateLevel(newLevel);
}
}
}
}

View File

@@ -1,6 +1,7 @@
package eu.midnightdust.yaytris.ui;
import eu.midnightdust.yaytris.HighScores;
import eu.midnightdust.yaytris.Settings;
import eu.midnightdust.yaytris.util.CatppuccinColor;
import javax.swing.*;
@@ -8,6 +9,7 @@ import javax.swing.border.LineBorder;
import java.awt.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static eu.midnightdust.yaytris.Translation.t;
import static eu.midnightdust.yaytris.ui.TetrisUI.scale;
@@ -20,11 +22,12 @@ public class HighScoreMenu extends JPanel {
this.setBounds(x, y, width, height);
this.setLayout(null);
this.add(new JLabel(t("highscores.title")));
this.add(new JLabel(t("highscores.title", Settings.difficulty)));
List<String> highscores = new ArrayList<>();
for (String key : HighScores.scores.keySet()) {
highscores.add(String.format("%s %s", HighScores.scores.get(key), key));
Map<String, Integer> scores = HighScores.diffToMap(Settings.difficulty);
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(" ", ""))));
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() {
this.setSize((int) (400 * guiScale), (int) (320 * guiScale));
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));
this.setLocationRelativeTo(null);
}
@@ -165,7 +165,7 @@ public class TetrisUI extends JFrame implements KeyListener {
*/
public void showHighscoreDialog(int score) {
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
*/
public class NightJson {
private static final String KEY_PATTERN = "\"(-?[A-Za-z-_.]*)\":";
private static final String KEY_PATTERN = "\"(.*)\":";
Class<?> jsonClass;
Field jsonMap;
String fileLocation;
@@ -175,21 +175,8 @@ public class NightJson {
private Object getValue(String s, String key, Function<String, Class<?>> keyToType, Iterator<String> pairIterator) {
String val = s.split(KEY_PATTERN, 2)[1];
if (s.contains("{")) { // Handle maps recursively
StringBuilder submapString = new StringBuilder();
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);
if (s.contains("{")) {
return readJsonMap(key, pairIterator, val);
}
else {
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.
*

View File

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

View File

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

View File

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