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.
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?
@@ -11,4 +12,36 @@ Können Sie meinen Highscore schlagen?
## 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.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.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

@@ -2,24 +2,26 @@ package eu.midnightdust.yaytris;
import eu.midnightdust.yaytris.util.Difficulty;
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;
@SuppressWarnings("unused") // Comments are unused in code, but are added to the JSON file for readability
public class Settings {
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 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 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 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 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 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 void load() {

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,6 @@
package eu.midnightdust.yaytris;
import eu.midnightdust.yaytris.util.NightJson;
import eu.midnightdust.yaytris.util.json.NightJson;
import java.io.IOException;
import java.io.InputStream;

View File

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

View File

@@ -1,16 +1,22 @@
package eu.midnightdust.yaytris.ui;
import eu.midnightdust.yaytris.util.CatppuccinColor;
import javax.swing.*;
import java.awt.*;
import static eu.midnightdust.yaytris.ui.TetrisUI.scale;
import static eu.midnightdust.yaytris.ui.TetrisUI.setFontScale;
public class AbstractMenu extends JPanel {
@Override
public Component add(Component comp) {
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);
}

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,20 +22,23 @@ 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));
highscoreList.setBackground(CatppuccinColor.BASE.getColor());
highscoreList.setForeground(CatppuccinColor.TEXT.getColor());
highscoreList.setSelectionForeground(CatppuccinColor.CRUST.getColor());
JScrollPane highscoreScrollPane = new JScrollPane(highscoreList);
highscoreScrollPane.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), 3, true));
this.add(highscoreScrollPane);
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"));
backButton.addActionListener(ui::openMainMenu);
@@ -45,6 +50,10 @@ public class HighScoreMenu extends JPanel {
public Component add(Component comp) {
if (comp instanceof JLabel) {
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);
}

View File

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

View File

@@ -12,6 +12,7 @@ import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.io.IOException;
import java.io.InputStream;
import static eu.midnightdust.yaytris.Settings.guiScale;
import static eu.midnightdust.yaytris.Translation.t;
@@ -20,6 +21,7 @@ public class TetrisUI extends JFrame implements KeyListener {
JLabel titleLabel;
GameCanvas gamePanel;
JPanel menuPanel;
Image titleImage;
/**
* Initialize the main Tetris GUI based on Swing
@@ -27,22 +29,21 @@ public class TetrisUI extends JFrame implements KeyListener {
public TetrisUI() {
this.setLayout(null);
this.setTitle("Tetris");
this.setSize((int) (400 * guiScale), (int) (300 * guiScale));
this.setSize((int) (400 * guiScale), (int) (320 * guiScale));
this.setResizable(false);
this.getContentPane().setBackground(CatppuccinColor.MANTLE.getColor());
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setWindowPosition(this, 0);
this.setLocationRelativeTo(null);
titleLabel = new JLabel("Tetris");
titleLabel.setForeground(Color.WHITE);
Image titleImage;
try {
titleImage = ImageIO.read(this.getClass().getResourceAsStream("/textures/logo.png"));
try (InputStream stream = this.getClass().getResourceAsStream("/textures/logo.png")) {
assert stream != null;
titleImage = ImageIO.read(stream);
} catch (IOException | NullPointerException ex) {
throw new RuntimeException(ex);
}
titleLabel = new JLabel();
titleLabel.setIcon(new ImageIcon(new ImageIcon(titleImage).getImage().getScaledInstance(scale(110), scale(30), Image.SCALE_DEFAULT)));
this.add(titleLabel);
gamePanel = new GameCanvas(this);
@@ -61,12 +62,11 @@ public class TetrisUI extends JFrame implements KeyListener {
* Resize all elements to match the current GUI scale.
*/
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));
gamePanel.setBounds(scale(10), scale(10), scale(150), scale(256));
for (Component comp : this.getComponents()){
if (comp instanceof JComponent) setFontScale((JComponent) comp);
}
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);
}
/**
@@ -77,9 +77,6 @@ public class TetrisUI extends JFrame implements KeyListener {
public static int scale(int bound) {
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() {
return gamePanel;
@@ -106,11 +103,11 @@ public class TetrisUI extends JFrame implements KeyListener {
*
* @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);
Tetris.resetSpace();
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.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel);
@@ -124,9 +121,9 @@ public class TetrisUI extends JFrame implements KeyListener {
*
* @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);
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.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel);
@@ -138,9 +135,9 @@ public class TetrisUI extends JFrame implements KeyListener {
*
* @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);
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.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel);
@@ -152,9 +149,9 @@ public class TetrisUI extends JFrame implements KeyListener {
*
* @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);
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.setBorder(new LineBorder(CatppuccinColor.SURFACE0.getColor(), scale(2)));
this.add(menuPanel);
@@ -168,41 +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);
}
/**
* 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);
if (playerName != null) HighScores.addScore(playerName, score);
}
/**

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
*/
public enum CatppuccinColor {
CRUST(0x11111b), MANTLE(0x181825), BASE(0x1e1e2e), SURFACE0(0x313244);
TEXT(0xcdd6f4), CRUST(0x11111b), MANTLE(0x181825), BASE(0x1e1e2e), SURFACE0(0x313244);
final Color color;
CatppuccinColor(int rgb) {

View File

@@ -1,9 +1,6 @@
package eu.midnightdust.yaytris.util;
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.io.IOException;
import java.io.InputStream;
@@ -17,21 +14,4 @@ public class FileUtil {
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.FileWriter;
@@ -12,12 +12,12 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* NightJson v0.2 by Martin Prokoph
* Extremely lightweight (and incomplete) JSON library
* NightJson v0.3 by Martin Prokoph
* Extremely lightweight JSON library
* 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;
@@ -39,28 +39,11 @@ public class NightJson {
/**
* Convert the current state of the java json class to actual json and save it to disk.
*/
@SuppressWarnings("unchecked")
public void writeJson() {
if (fileLocation == null) return;
try {
FileWriter jsonFile = new FileWriter(fileLocation);
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();
writeJsonToFile(jsonFile);
} catch (IOException | IllegalAccessException e) {
System.out.println("Oh no! An Error occurred whilst writing the JSON file :(");
//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.
*/
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");
if (type == Comment.class) {
jsonFile.write(String.format("// %s\n", ((Comment) value).commentString));
@@ -90,7 +93,7 @@ public class NightJson {
StringBuilder mapPairs = new StringBuilder();
Map<?, ?> map = (Map<?, ?>) value;
Iterator<?> it = map.keySet().iterator();
if (it.hasNext()) mapPairs.append("{");
mapPairs.append(it.hasNext() ? "{" : "{}");
while (it.hasNext()) {
Object key = it.next();
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.
*/
@SuppressWarnings("unchecked")
public void readJsonFromString(String jsonString) {
try {
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);
}
}
readJsonString(jsonString);
} catch (IllegalAccessException | NoSuchElementException | ClassCastException e) {
System.out.println("Oh no! An Error occurred whilst reading the JSON file :(");
//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.
*/
@@ -168,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);
@@ -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.
*
@@ -249,20 +264,4 @@ public class NightJson {
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();
} catch (LineUnavailableException | IOException | UnsupportedAudioFileException 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);
} catch (LineUnavailableException | IOException | UnsupportedAudioFileException 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.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": {}
}

View File

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