diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d134928 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Default formatting Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# do not trim trailing whitespace in markdown files +[*.md] +trim_trailing_whitespace = false + +# explicit 4 space indentation +[*.py] +indent_size = 4 + +# explicit 2 space indentation +[*.{json,yml,yaml,xml,ddl,sql}] +indent_size = 2 + +# windows specific files +[*.{bat,cmd}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8255993 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a bug report +title: '' +labels: 'fix' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..431bc54 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,8 @@ +--- +name: Blank +about: Create a blank issue +title: '' +labels: '' +assignees: '' + +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c76ac3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Create a feature request +title: '' +labels: 'feat' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/assets/execution.svg b/.github/assets/execution.svg new file mode 100644 index 0000000..b7f069f --- /dev/null +++ b/.github/assets/execution.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000..ce7eec9 Binary files /dev/null and b/.github/assets/logo.png differ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a581da6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +# Resolves #000 + +## Description + +Please provide a brief description of the changes you've made in this pull request. + +## Checklist + +Please make sure that the following items have been completed before submitting this pull request: + +- [ ] All code has been properly tested +- [ ] All tests pass successfully +- [ ] Code has been reviewed for clarity, readability, and maintainability +- [ ] Code has been properly documented with JavaDoc comments diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc3f077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# InelliJ IDEA files +*.iml +*.ipr +*.ids +*.iws +.idea/ + +# Eclipse files +.project +.metadata +.classpath +.settings/ +.loadpath +bin/ + +# Netbeans +nbactions.xml + +# Visual Studio Code +.vscode + +# Maven +target/ + +# gradle files +.gradle +build/ + +# ignore logfiles +*.log* + +# OS dependant files +.DS_Store +.Spotlight-V100 +.Trashes +Thumbs.db +Desktop.ini +*~ +# Thumbnails +._* + +# compiled files +*.com +*.class +*.dll +*.exe +*.o +*.so + +# packages +*.7z +#*.jar +*.rar +*.zip +*.gz +*.bzip +*.xz +*.lzma +*~$* + +# package managment formats +*.dmg +*.xpi +*.gem +*.egg +*.deb +*.rpm + +# databases +*.sqlite + +# Ignore Gradle build output directory +build + +# Ignore Java Utils Properties Files +settings.properties +statistics.properties diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7edc7da --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,75 @@ +# Code of Conduct + +## Conventional Commits +Developers should use the Conventional Commits standard when committing changes to the codebase. + +| Type | Description | +| -------- | --------------------------------------------------------------------- | +| feat | Declares a new feature has been added | +| fix | Declares a bug have been fixed | +| chore | Declares changes which don’t modify source or test files (eg. assets) | +| ci | Declares a change on the CI or CD process | +| build | Declares changes on the build setup | +| docs | Declares changes on documentation | +| refactor | Declares a change of code without an effective change on the program | +| revert | Declares that a previous commit has been reverted | +| test | Declares changes on tests | + +### Examples + +#### Commit Message +``` +refactor: adjust vehicle texture size [#ISSUENUMBER] +refactor: adjust vehicle texture size [NOISSUE] +``` + +#### Branch Name +``` +refactor/#ISSUENUMBER_adjust-vehicle-texture-size +refactor/NOISSUE_adjust-vehicle-texture-size +``` + +## Contributing +Developers should follow the following guidelines when contributing to the project: + +### 1. Create a new branch +When starting work on a new feature or bug fix, create a new branch from the `main` branch. The name of the branch should be descriptive and should include the issue number and a short description of the feature or bug fix. For example, if you are working on issue #123, the branch name should be `feat/#123_add-new-feature`. + +### 2. Commit changes +When committing changes to the codebase, developers should follow the [Conventional Commits](#conventional-commits) standard. This will ensure that the commit messages are consistent and descriptive, and will allow the commit history to be automatically parsed to generate release notes. + +### 3. Create a draft pull request +After committing changes to the codebase, create a draft pull request to inform other developers that you are working on a new feature or bug fix. The pull request should be kept in draft mode until the feature or bug fix is complete. + +### 4. Create a pull request +When the feature or bug fix is complete, mark the pull request as ready to review to merge the changes into the `main` branch. The pull request should be reviewed by at least one other developer before it can be merged. + +### 5. Review pull request +When a pull request is marked as ready for review, it should be reviewed by at least one other developer. The reviewer should verify that the code meets the [Definition of Done](#definition-of-done). + +### 6. Merge pull request +Once the pull request has been reviewed and approved, it can be merged into the `main` branch. The pull request should be merged using the "Rebase and merge" option to ensure that the commit history remains clean and concise. + +## Definition of Done + +### 1. Code meets coding standards +All code must adhere to the rules defined in the Clean Code handbook for at least level L1. Level L2 rules should also be taken into consideration. Specifically, emphasis should be placed on: + +1. Correct abstraction level: The code should have a clear and appropriate level of abstraction, with well-defined interfaces and separation of concerns. +2. Class diagram: The class diagram should be clear and well-organized, with high cohesion and low coupling between classes. +3. Correct error handling: The code should handle errors correctly, including validating arguments and handling exceptions in a consistent and appropriate manner. + +### 2. Unit tests pass +All code changes must be accompanied by unit tests that verify the expected behavior of the code. These tests must pass without any errors or failures before the code can be considered complete. + +### 3. Code is reviewed +All code must be reviewed by at least one other developer to ensure quality and compliance with coding standards. The code review should focus on identifying any bugs, security vulnerabilities, or design flaws that could impact the quality or maintainability of the code. + +### 4. Documentation is complete +All code must be fully documented, including comments within the code and external documentation such as user manuals. The documentation should be comprehensive and accurate, and should provide enough detail for other developers and stakeholders to understand the code. + +### 5. Acceptance criteria are met +The code must meet all of the acceptance criteria as defined by the stakeholders. These acceptance criteria are used as a basis for verifying that the code meets the intended requirements + +### 6. Security is considered +The code must be reviewed for security vulnerabilities and any identified issues must be addressed. The code should be designed with security in mind, and should be subject to regular security testing to identify any new vulnerabilities. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6aab65b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1 @@ +Copyright © 2023 Boostvolt (Jan). All rights reserved. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc9e33f --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# AmongDigits + +## Overview + +AmongDigits is a JavaFX based Sudoku Game where users can play and create their own Sudokus. It is multi-language, +intuitive, has different themes and even statistics. + +## Design & class diagram + +Check out the [design & class diagram](./docs/design-class-diagram.md) to learn more about the +design and technical details including the class diagram. + +## Installation & Running + +1. Install prerequisites: + - OpenJDK version 17 or higher + - Optional: Gradle + => [Installation Manual](https://docs.gradle.org/current/userguide/installation.html) + +2. Clone the repository + +3. Configure your IDE to use Gradle + - IntelliJ: Gradle Plugin is installed and enabled by default. + +4. Run the game from the IDE or the terminal: + - ``gradle run`` + + +## GitHub Workflow + +### Code of Conduct + +Our [Code of Conduct](CODE_OF_CONDUCT.md) describes the rules and guidelines for contributing to our +project. + +### Branching Model + +We used the feature branching workflow for several reasons: + +- Firstly, it promotes better organization and management of code changes, especially those + involving multiple team members working on different features or tasks simultaneously. By creating + separate branches for each feature or task, developers can work on their code changes + independently without interfering with the work of others. This reduces the likelihood of + conflicts arising between different changes, which can be time-consuming to resolve. + +- Secondly, the feature branching workflow also enables better tracking of code changes and easier + identification of issues or bugs. Since each feature branch contains changes related to a specific + feature or task, it is easier to pinpoint issues and resolve them quickly. + +- Finally, the feature branching workflow also facilitates better quality control and helps ensure + that the project's overall codebase remains stable and functional. Changes are tested and reviewed + before they are merged back into the main branch, reducing the risk of introducing bugs or errors + into the production code. This ensures that the final product is of high quality and meets the + requirements of the stakeholders. + +## Time planning (Weekly summed up efforts) + +### Week 1 (14.04.23 - 21.04.23) + +- 10h total effort + +### Week 2 (21.04.23 - 28.04.23) + +- 15h total effort + +### Week 3 (28.04.23 - 05.05.23) + +- 45h total effort + +### Week 4 (05.05.23 - 12.05.23) + +- 75h total effort diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d7a4a65 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Reporting Security Issues + +If you believe you have found a security vulnerability in the codebase, we encourage you to let us +know right away. + +We will investigate all legitimate reports and do our best to quickly fix the problem. \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..088e891 --- /dev/null +++ b/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'java' + id 'application' + id 'org.openjfx.javafxplugin' version '0.0.13' +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + testImplementation 'org.mockito:mockito-core:5.2.0' + + implementation "org.slf4j:slf4j-api:2.0.7" + implementation 'ch.qos.logback:logback-classic:1.4.6' + + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok:1.18.26' + + testCompileOnly 'org.projectlombok:lombok:1.18.26' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.26' +} + +group = 'ch.zhaw.pm2' +version = '2023' + +application { + mainClass = 'ch.zhaw.pm2.amongdigits.App' +} + +javafx { + version = "20" + modules = ['javafx.controls', 'javafx.fxml', 'javafx.media'] +} + +test { + useJUnitPlatform() +} diff --git a/docs/amongdigits-class-diagram-png.png b/docs/amongdigits-class-diagram-png.png new file mode 100644 index 0000000..c122864 Binary files /dev/null and b/docs/amongdigits-class-diagram-png.png differ diff --git a/docs/design-class-diagram.md b/docs/design-class-diagram.md new file mode 100644 index 0000000..718ae75 --- /dev/null +++ b/docs/design-class-diagram.md @@ -0,0 +1,83 @@ +# Design + +## MVC + +- Our application has for every `ScreenType` its own `FXML` file. This was created using Scene Builder. +- All these screens have their own `Controller` and `Model`. +- Every screen is defined in the enum `ScreenType`. This allows for a central place where all the links to the `FXML` + files are located. +- Each `Controller` extends from the abstract superclass `ControlledScreen` to automatically give access to + the `SudokuGui` so the `Controllers` can forward actions after an event accordingly. +- The `Controller` defines on each `FXML` element the appropriate action. +- `Models` are used to contain the data being displayed e.g. `ChallengesModel` or `SudokuGameModel` + +## Sudoku Generator & Solver + +- `SudokuSolver`: + - Implements the logic to solve a given Sudoku. + - Uses a recursive backtracking algorithm to solve the puzzle. + - Includes methods to check if a number can be placed in a particular cell. +- `SudokuGenerator`: + - Implements the logic to generate a new Sudoku. + - Uses a recursive algorithm to fill the puzzle with numbers. + - Includes methods to print the generated puzzle and to remove cells to create a new puzzle with a specified + `DifficultyLevel`. +- It contains three interfaces to allow for flexible generation and solving: + - `Matrix`: Acts as a generic interface which can also deal with non-Sudoku like grids. It offers methods to set and + get cells and also validates it. + - `Sudoku`: Extends the `Matrix` interface which is used by the `SudokuManager` to create and solve a 9x9 grid. + - `Schema`: Interface to have different `SchemaTypes`, in our case we only have a 9x9 grid for now. + +## Logging & Exception-Handling + +- For any kind of exception e.g. invalid file for upload provided or if a Sudoku could not be loaded, we display alert + windows. +- The util class `AlertBuilder` class is responsible for creating these in a unifying way. +- Logging was implemented to have information available when something went wrong. + +## Properties + +- This application uses property files to provide a better user experience by being able to customize the game and have + this customization reloaded upon start of the application. +- It is divided into two separate files: + - Settings: Used to store all the settings such as selected language and if dark mode is enabled or not. + - Statistics: Used to store the statistics, so they can be displayed at any time. +- To access properties in an easy way, the static class `PropertiesHandler` was created to have a central place for + that. + +## Multi-Language + +- To support multiple languages, we implemented `MessageBundles` properties. +- There is one for the default, one for English and one for German. +- It can be extended anytime very easily just by adding a new `MessageBundle` property file for the new message and + provide a translation for every label. +- The property files contain labels which are being referred two in two different ways: + - From `FXML` files using `text="%label"` + - From the code directly using `String.format(resourceBundle.getString("label"), Object... args))`. This for example + allows for localized exception messages. + +## Sudoku Upload + +- The upload mechanism was implemented in a generic way to allow for different types in the future: + - The `FileValidator` can expect any grid size, file separator and empty grid cell + - That way it is flexible and reusable in case a new type of Sudoku grid game needs to be parsed +- A user only has to provide the unsolved grid of the Sudoku. It then parses the Sudoku and uses the Solver to determine + if the Sudoku has one unique solution. It also determines the `DifficultyLevel` which specifies how many numbers needs + to be cleared maximum, how many mistakes can be made and the timer. +- Once validated, it saves both the unsolved and solved grid as a `SudokuBoard` object in a new file. This allows for + direct loading in the Challenges section, as we do not need to check again if it is solvable with one unique solution. +- To prevent having the same Sudoku name + grids saved multiple times, it calculates the hashcode of the `SudokuBoard` + to check if the exact same grids with the same name has been uploaded already. + +## Challenges + +- There are two different type of Challenges which are displayed using a `ListView`: + - Pre-generated: + - User-generated: +- The enum `ChallengeType` contains the location where these are stored in. +- To have a specific display for each challenge entry and not just have the `toString()` from the `File` class called, a + `FileCellFactory` was implemented to override what should be displayed. + +# Class diagram + +![amongdigits-class-diagram-png.png](amongdigits-class-diagram-png.png) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..943f0cb Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0c85a1f --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..65dcd68 --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a6b4988 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,11 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user manual at https://docs.gradle.org/7.6/userguide/multi_project_builds.html + */ + +rootProject.name = 'Among Digits' +include('app') diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/App.java b/src/main/java/ch/zhaw/pm2/amongdigits/App.java new file mode 100644 index 0000000..8d6d4df --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/App.java @@ -0,0 +1,20 @@ +/* + * This Java source file was generated by the Gradle 'init' task. + */ + +package ch.zhaw.pm2.amongdigits; + +import javafx.application.Application; + +/** The main entry point for the application. */ +public class App { + + /** + * The main method that launches the application. + * + * @param args The command line arguments. + */ + public static void main(String[] args) { + Application.launch(SudokuGui.class, args); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/ChallengeType.java b/src/main/java/ch/zhaw/pm2/amongdigits/ChallengeType.java new file mode 100644 index 0000000..4c74f69 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/ChallengeType.java @@ -0,0 +1,31 @@ +package ch.zhaw.pm2.amongdigits; + +/** Enumeration representing different types of Sudoku challenges. */ +public enum ChallengeType { + + /** Pre-generated Sudoku challenges. */ + PRE_GENERATED("sudokus/pre-generated"), + + /** User-generated Sudoku challenges. */ + USER_GENERATED("sudokus/upload"); + + private final String directory; + + /** + * Constructs a ChallengeType enum constant with the specified directory. + * + * @param directory The directory associated with the challenge type. + */ + ChallengeType(String directory) { + this.directory = directory; + } + + /** + * Returns the directory associated with the challenge type. + * + * @return The directory. + */ + public String getDirectory() { + return directory; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/ControlledScreen.java b/src/main/java/ch/zhaw/pm2/amongdigits/ControlledScreen.java new file mode 100644 index 0000000..5151df8 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/ControlledScreen.java @@ -0,0 +1,26 @@ +package ch.zhaw.pm2.amongdigits; + +/** + * Abstract class that serves as a base for all screens that need to be controlled by the {@link + * SudokuGui}. Provides a way to access the SudokuGui instance and set it. + */ +public abstract class ControlledScreen { + /** The instance of the {@link SudokuGui} controlling this screen. */ + private SudokuGui sudokuGui; + /** + * Sets the SudokuGui instance that controls this screen. + * + * @param sudokuGui the SudokuGui instance that controls this screen + */ + protected final void setSudokuGui(SudokuGui sudokuGui) { + this.sudokuGui = sudokuGui; + } + /** + * Gets the SudokuGui instance that controls this screen. + * + * @return the SudokuGui instance that controls this screen + */ + protected SudokuGui getSudokuGui() { + return sudokuGui; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/DifficultyLevel.java b/src/main/java/ch/zhaw/pm2/amongdigits/DifficultyLevel.java new file mode 100644 index 0000000..3a2075c --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/DifficultyLevel.java @@ -0,0 +1,128 @@ +package ch.zhaw.pm2.amongdigits; + +import java.util.Arrays; +import java.util.Comparator; + +/** Enumeration representing different difficulty levels for Sudoku challenges. */ +public enum DifficultyLevel implements Comparable { + + /** Beginner difficulty level. */ + BEGINNER("beginner", 25, 2, 600), + + /** Easy difficulty level. */ + EASY("easy", 32, 4, 1200), + + /** Medium difficulty level. */ + MEDIUM("medium", 40, 6, 2400), + + /** Hard difficulty level. */ + HARD("hard", 53, 8, 3600), + + /** Expert difficulty level. */ + EXPERT("expert", 64, 10, 7200); + + private final String translationProperty; + private final int maxNumbersToClear; + private final int maxErrorsToSolve; + private final int maxSecondsToSolve; + + /** + * Constructs a DifficultyLevel enum constant with the specified properties. + * + * @param translationProperty The translation property associated with the difficulty level. + * @param maxNumbersToClear The maximum number of numbers to clear in the Sudoku grid. + * @param maxErrorsToSolve The maximum number of errors allowed to solve the Sudoku. + * @param maxSecondsToSolve The maximum number of seconds allowed to solve the Sudoku. + */ + DifficultyLevel( + String translationProperty, + int maxNumbersToClear, + int maxErrorsToSolve, + int maxSecondsToSolve) { + this.translationProperty = translationProperty; + this.maxNumbersToClear = maxNumbersToClear; + this.maxErrorsToSolve = maxErrorsToSolve; + this.maxSecondsToSolve = maxSecondsToSolve; + } + + /** + * Determines the difficulty level based on the number of cells to clear in the Sudoku grid. + * + * @param unsolvedGrid The unsolved Sudoku grid. + * @return The difficulty level. + */ + public static DifficultyLevel determineDifficultyLevel(final byte[][] unsolvedGrid) { + int numbersToClear = 0; + for (byte[] currentRow : unsolvedGrid) { + for (byte cells : currentRow) { + if (cells == 0) { + numbersToClear++; + } + } + } + + final DifficultyLevel[] difficultyLevels = DifficultyLevel.values(); + Arrays.sort(difficultyLevels, new DifficultyLevel.DifficultyLevelComparator().reversed()); + for (DifficultyLevel difficultyLevel : difficultyLevels) { + if (numbersToClear >= difficultyLevel.getMaxNumbersToClear()) { + return difficultyLevel; + } + } + return difficultyLevels[difficultyLevels.length - 1]; + } + + /** + * Returns the translation property associated with the difficulty level. + * + * @return The translation property. + */ + public String getTranslationProperty() { + return translationProperty; + } + + /** + * Returns the maximum number of numbers to clear in the Sudoku grid. + * + * @return The maximum number of numbers to clear. + */ + public int getMaxNumbersToClear() { + return maxNumbersToClear; + } + + /** + * Returns the maximum number of errors allowed to solve the Sudoku. + * + * @return The maximum number of errors to solve. + */ + public int getMaxErrorsToSolve() { + return maxErrorsToSolve; + } + + /** + * Returns the maximum number of seconds allowed to solve the Sudoku. + * + * @return The maximum number of seconds to solve. + */ + public int getMaxSecondsToSolve() { + return maxSecondsToSolve; + } + + /** + * A comparator implementation used to compare DifficultyLevel objects based on their + * maxNumbersToClear property. + */ + private static class DifficultyLevelComparator implements Comparator { + + /** + * Compares two DifficultyLevel objects based on their maxNumbersToClear property. + * + * @param level1 The first DifficultyLevel to compare. + * @param level2 The second DifficultyLevel to compare. + * @return The result of the comparison. + */ + @Override + public int compare(final DifficultyLevel level1, final DifficultyLevel level2) { + return Integer.compare(level1.getMaxNumbersToClear(), level2.getMaxNumbersToClear()); + } + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/PropertyType.java b/src/main/java/ch/zhaw/pm2/amongdigits/PropertyType.java new file mode 100644 index 0000000..b4d4e0b --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/PropertyType.java @@ -0,0 +1,30 @@ +package ch.zhaw.pm2.amongdigits; + +/** An enumeration representing the types of property files. */ +public enum PropertyType { + /** The SETTINGS property type. */ + SETTINGS("src/main/resources/properties/settings.properties"), + + /** The STATISTICS property type. */ + STATISTICS("src/main/resources/properties/statistics.properties"); + + private final String fileName; + + /** + * Constructs a new PropertyType with the specified file name. + * + * @param fileName The file name associated with the property type. + */ + PropertyType(String fileName) { + this.fileName = fileName; + } + + /** + * Returns the file name associated with the property type. + * + * @return The file name. + */ + public String getFileName() { + return fileName; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/ScreenType.java b/src/main/java/ch/zhaw/pm2/amongdigits/ScreenType.java new file mode 100644 index 0000000..152744c --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/ScreenType.java @@ -0,0 +1,42 @@ +package ch.zhaw.pm2.amongdigits; + +/** An enumeration representing the types of screens in the application. */ +public enum ScreenType { + /** The main menu screen. */ + MAIN_MENU("/views/MainMenu.fxml"), + + /** The settings screen. */ + SETTINGS("/views/Settings.fxml"), + + /** The statistics screen. */ + STATISTICS("/views/Statistics.fxml"), + + /** The new game menu screen. */ + NEW_GAME_MENU("/views/NewGameMenu.fxml"), + + /** The Sudoku game screen. */ + SUDOKU("/views/SudokuGameView.fxml"), + + /** The challenges screen. */ + CHALLENGES("/views/Challenges.fxml"); + + private final String fileName; + + /** + * Constructs a new ScreenType with the specified file name. + * + * @param fileName The file name associated with the screen type. + */ + ScreenType(String fileName) { + this.fileName = fileName; + } + + /** + * Returns the file name associated with the screen type. + * + * @return The file name. + */ + public String getFileName() { + return fileName; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/SudokuBoard.java b/src/main/java/ch/zhaw/pm2/amongdigits/SudokuBoard.java new file mode 100644 index 0000000..d3a72d5 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/SudokuBoard.java @@ -0,0 +1,77 @@ +package ch.zhaw.pm2.amongdigits; + +import static java.util.Objects.requireNonNull; + +import java.util.Arrays; + +/** + * A record representing a Sudoku board. + * + *

The SudokuBoard record contains the unsolved grid, solved grid, and the difficulty level of + * the Sudoku board. + */ +public record SudokuBoard( + byte[][] unsolvedGrid, byte[][] solvedGrid, DifficultyLevel difficultyLevel) { + + /** + * Constructs a SudokuBoard with the specified unsolved grid, solved grid, and difficulty level. + * + * @param unsolvedGrid The unsolved grid of the Sudoku board. + * @param solvedGrid The solved grid of the Sudoku board. + * @param difficultyLevel The difficulty level of the Sudoku board. + */ + public SudokuBoard( + final byte[][] unsolvedGrid, + final byte[][] solvedGrid, + final DifficultyLevel difficultyLevel) { + this.unsolvedGrid = requireNonNull(unsolvedGrid); + this.solvedGrid = requireNonNull(solvedGrid); + this.difficultyLevel = requireNonNull(difficultyLevel); + } + + /** + * Indicates whether some other object is "equal to" this one. + * + * @param o The object to compare. + * @return {@code true} if this object is the same as the o argument; {@code false} otherwise. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SudokuBoard that = (SudokuBoard) o; + return Arrays.deepEquals(unsolvedGrid, that.unsolvedGrid) + && Arrays.deepEquals(solvedGrid, that.solvedGrid) + && difficultyLevel == that.difficultyLevel; + } + + /** + * Returns a hash code value for the object. This method does not consider the DifficultyLevel to + * provide a unique hash code for each SudokuBoard, which can be used when persisting the files. + * + * @return The hash code of this {@link SudokuBoard}. + */ + @Override + public int hashCode() { + int result = Arrays.deepHashCode(unsolvedGrid); + result = 31 * result + Arrays.deepHashCode(solvedGrid); + return result; + } + + /** + * Returns a string representation of the SudokuBoard. + * + * @return A string representation of the SudokuBoard. + */ + @Override + public String toString() { + return "SudokuBoard{" + + "unsolvedGrid=" + + Arrays.toString(unsolvedGrid) + + ", solvedGrid=" + + Arrays.toString(solvedGrid) + + ", difficultyLevel=" + + difficultyLevel + + '}'; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/SudokuGui.java b/src/main/java/ch/zhaw/pm2/amongdigits/SudokuGui.java new file mode 100644 index 0000000..32d9009 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/SudokuGui.java @@ -0,0 +1,215 @@ +package ch.zhaw.pm2.amongdigits; + +import static ch.zhaw.pm2.amongdigits.PropertyType.SETTINGS; +import static ch.zhaw.pm2.amongdigits.ScreenType.CHALLENGES; +import static ch.zhaw.pm2.amongdigits.ScreenType.MAIN_MENU; +import static ch.zhaw.pm2.amongdigits.ScreenType.STATISTICS; +import static ch.zhaw.pm2.amongdigits.ScreenType.SUDOKU; +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; +import static javafx.scene.control.Alert.AlertType.ERROR; +import static javafx.scene.media.MediaPlayer.INDEFINITE; + +import ch.zhaw.pm2.amongdigits.controller.SudokuGameController; +import ch.zhaw.pm2.amongdigits.exception.InvalidFileFormatException; +import ch.zhaw.pm2.amongdigits.exception.InvalidSudokuException; +import ch.zhaw.pm2.amongdigits.utils.PropertiesHandler; +import ch.zhaw.pm2.amongdigits.utils.alert.AlertBuilder; +import ch.zhaw.pm2.amongdigits.utils.alert.AlertOptions; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.*; +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.stage.Stage; +import lombok.extern.slf4j.Slf4j; + +/** + * The graphical user interface for the Sudoku game. + * + *

The SudokuGui class extends the JavaFX Application class and provides methods for managing the + * game screens, changing the stylesheet, initializing new games, and reloading screens. + */ +@Slf4j +public class SudokuGui extends Application { + + private static final String LANGUAGE = "language"; + private static final double GAME_MUSIC_VOLUME = 0.2; + private final Map screens = new EnumMap<>(ScreenType.class); + private Scene scene; + private MediaPlayer mainMenuMediaPlayer; + private MediaPlayer gameMediaPlayer; + + /** + * Starts the Sudoku game GUI by initializing the music players and loading the screens. + * + * @param primaryStage The primary stage for the application. + */ + @Override + public void start(Stage primaryStage) { + try { + initMusicPlayers(); + } catch (URISyntaxException e) { + log.error("Error loading menu music"); + } + EnumSet.allOf(ScreenType.class).forEach(this::loadScreen); + mainWindow(primaryStage); + setStyleSheet( + requireNonNull( + getClass() + .getResource(PropertiesHandler.getPropertyString(SETTINGS, "cssFileString"))) + .toString()); + } + + /** + * Sets the stylesheet for the scene. + * + * @param styleSheetPath The path to the stylesheet file. + */ + public void setStyleSheet(String styleSheetPath) { + scene.getStylesheets().clear(); + scene.getStylesheets().add(styleSheetPath); + } + + /** + * Creates the main window of the Sudoku game with the specified primaryStage. + * + * @param primaryStage The primary stage for the application. + */ + public void mainWindow(Stage primaryStage) { + Parent rootPane = screens.get(MAIN_MENU); + scene = new Scene(rootPane); + + primaryStage.setScene(scene); + primaryStage.setMinWidth(850); + primaryStage.setMinHeight(650); + primaryStage.setTitle("Among Digits"); + primaryStage.show(); + } + + /** + * Changes the screen to the specified screen type. + * + * @param screenType The screen type to change to. + */ + public void changeScreenTo(ScreenType screenType) { + if (screenType == MAIN_MENU) { + gameMediaPlayer.stop(); + mainMenuMediaPlayer.play(); + } else if (screenType == SUDOKU) { + mainMenuMediaPlayer.stop(); + gameMediaPlayer.play(); + } + if (screenType == STATISTICS) { + loadScreen(STATISTICS); + } + scene.setRoot(screens.get(screenType)); + } + + /** Reloads the game screens. */ + public void reloadScreens() { + for (ScreenType type : EnumSet.allOf(ScreenType.class)) { + loadScreen(type); + } + changeScreenTo(ScreenType.SETTINGS); + } + + /** + * Initializes a new game with the specified difficulty level. + * + * @param difficultyLevel The difficulty level of the new game. + */ + public void initializeNewGameWithDifficulty(DifficultyLevel difficultyLevel) { + initializeNewGame(difficultyLevel); + } + + /** + * Initializes a new game with the specified Sudoku file. + * + * @param sudokuFile The Sudoku file to load the game from. + */ + public void initializeNewGameWithFile(final File sudokuFile) { + initializeNewGame(sudokuFile); + } + + /** Reloads the challenges screen. */ + public void reloadChallenges() { + loadScreen(CHALLENGES); + } + + private void initMusicPlayers() throws URISyntaxException { + final Media mainMenuMusic = + new Media( + requireNonNull(getClass().getResource("/media/menu-music.mp3")).toURI().toString()); + final Media gameMenuMusic = + new Media( + requireNonNull(getClass().getResource("/media/game-music.mp3")).toURI().toString()); + mainMenuMediaPlayer = new MediaPlayer(mainMenuMusic); + mainMenuMediaPlayer.setCycleCount(INDEFINITE); + mainMenuMediaPlayer.play(); + gameMediaPlayer = new MediaPlayer(gameMenuMusic); + gameMediaPlayer.setVolume(GAME_MUSIC_VOLUME); + gameMediaPlayer.setCycleCount(INDEFINITE); + } + + private void initializeNewGame(Object parameter) { + ResourceBundle bundle = + ResourceBundle.getBundle( + "languages.MessagesBundle", + new Locale(PropertiesHandler.getPropertyString(SETTINGS, LANGUAGE))); + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource(SUDOKU.getFileName()), bundle); + Parent loadScreen = loader.load(); + SudokuGameController sudokuGameController = loader.getController(); + sudokuGameController.setSudokuGui(this); + + if (parameter instanceof DifficultyLevel difficultyLevel) { + sudokuGameController.createSudoku(difficultyLevel); + } else if (parameter instanceof File file) { + sudokuGameController.createSudoku(file); + } else { + throw new IllegalArgumentException( + "Given parameter is not a compatible Sudoku initializer"); + } + + screens.put(SUDOKU, loadScreen); + changeScreenTo(SUDOKU); + } catch (IOException | InvalidFileFormatException | InvalidSudokuException e) { + log.error(format("Error loading sudoku %s: %s", parameter, e.getMessage())); + AlertBuilder.showAlert( + new AlertOptions( + ERROR, + bundle.getString("sudoku_load_failed_title"), + null, + bundle.getString("sudoku_load_failed"), + null, + Collections.emptySet())); + } + } + + private void loadScreen(ScreenType type) { + try { + Locale locale; + if (PropertiesHandler.getPropertyString(SETTINGS, LANGUAGE).isBlank()) { + locale = Locale.getDefault(); + } else { + locale = new Locale(PropertiesHandler.getPropertyString(SETTINGS, LANGUAGE)); + } + ResourceBundle bundle = ResourceBundle.getBundle("languages.MessagesBundle", locale); + FXMLLoader loader = new FXMLLoader(getClass().getResource(type.getFileName()), bundle); + Parent loadScreen = loader.load(); + + ControlledScreen controlledScreen = loader.getController(); + controlledScreen.setSudokuGui(this); + + screens.put(type, loadScreen); + } catch (IOException e) { + log.error(format("Error loading screen: %s", e.getMessage())); + } + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/controller/ChallengesController.java b/src/main/java/ch/zhaw/pm2/amongdigits/controller/ChallengesController.java new file mode 100644 index 0000000..79d9e21 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/controller/ChallengesController.java @@ -0,0 +1,98 @@ +package ch.zhaw.pm2.amongdigits.controller; + +import static ch.zhaw.pm2.amongdigits.ChallengeType.PRE_GENERATED; +import static ch.zhaw.pm2.amongdigits.ChallengeType.USER_GENERATED; +import static ch.zhaw.pm2.amongdigits.ScreenType.MAIN_MENU; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.FILE_AREA_NAME_SEPARATOR; + +import ch.zhaw.pm2.amongdigits.ControlledScreen; +import ch.zhaw.pm2.amongdigits.DifficultyLevel; +import ch.zhaw.pm2.amongdigits.model.ChallengesModel; +import java.io.File; +import java.util.ResourceBundle; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.util.Callback; + +/** + * The ChallengesController class is responsible for controlling the screen where users can select + * and play pre-generated or user-generated Sudoku challenges. + */ +public class ChallengesController extends ControlledScreen { + + @FXML private ResourceBundle resources; + @FXML private ListView preGeneratedChallenges; + @FXML private Button playPreGenerated; + @FXML private ListView userGeneratedChallenges; + @FXML private Button playUserGenerated; + @FXML private Button mainMenu; + + /** + * Initializes the ChallengesController object by setting up the main menu button and loading + * pre-generated and user-generated challenges. + */ + @FXML + public void initialize() { + mainMenu.setOnAction(event -> getSudokuGui().changeScreenTo(MAIN_MENU)); + final ChallengesModel challengesModel = new ChallengesModel(); + challengesModel.load(); + preGeneratedChallenges.getItems().addAll(challengesModel.getChallenges().get(PRE_GENERATED)); + userGeneratedChallenges.getItems().addAll(challengesModel.getChallenges().get(USER_GENERATED)); + final FileCellFactory fileCellFactory = new FileCellFactory(); + preGeneratedChallenges.setCellFactory(fileCellFactory); + userGeneratedChallenges.setCellFactory(fileCellFactory); + playPreGenerated.setOnAction(event -> initializeOnPlay(preGeneratedChallenges)); + playUserGenerated.setOnAction(event -> initializeOnPlay(userGeneratedChallenges)); + } + + /** A callback function that sets up the display for a single file in the ListView. */ + public class FileCellFactory implements Callback, ListCell> { + + /** + * Creates a new ListCell for a file. + * + * @param param The ListView that the cell belongs to. + * @return The ListCell for the file. + */ + @Override + public ListCell call(ListView param) { + return new ListCell<>() { + /** + * Updates the display for the cell. + * + * @param file The file to display. + * @param empty Whether the cell is empty. + */ + @Override + public void updateItem(final File file, final boolean empty) { + super.updateItem(file, empty); + if (empty || file == null) { + setText(null); + } else { + setText(resolveDisplayedFileName(file.getName())); + } + } + }; + } + + private String resolveDisplayedFileName(final String fileName) { + final DifficultyLevel difficultyLevel = + DifficultyLevel.valueOf( + fileName.substring(0, fileName.indexOf(FILE_AREA_NAME_SEPARATOR))); + final String sudokuName = + fileName.substring( + fileName.indexOf(FILE_AREA_NAME_SEPARATOR) + 1, + fileName.lastIndexOf(FILE_AREA_NAME_SEPARATOR)); + return resources.getString(difficultyLevel.getTranslationProperty()) + ": " + sudokuName; + } + } + + private void initializeOnPlay(final ListView challenges) { + final File selectedChallenge = challenges.getSelectionModel().getSelectedItem(); + if (selectedChallenge != null) { + getSudokuGui().initializeNewGameWithFile(selectedChallenge); + } + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/controller/MainMenuController.java b/src/main/java/ch/zhaw/pm2/amongdigits/controller/MainMenuController.java new file mode 100644 index 0000000..6120476 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/controller/MainMenuController.java @@ -0,0 +1,30 @@ +package ch.zhaw.pm2.amongdigits.controller; + +import static ch.zhaw.pm2.amongdigits.ScreenType.CHALLENGES; +import static ch.zhaw.pm2.amongdigits.ScreenType.NEW_GAME_MENU; +import static ch.zhaw.pm2.amongdigits.ScreenType.SETTINGS; +import static ch.zhaw.pm2.amongdigits.ScreenType.STATISTICS; + +import ch.zhaw.pm2.amongdigits.ControlledScreen; +import javafx.fxml.FXML; +import javafx.scene.control.Button; + +/** + * The MainMenuController class is responsible for controlling the main menu screen of the game. + * This class extends the ControlledScreen abstract class to provide access to the SudokuGui object. + */ +public class MainMenuController extends ControlledScreen { + + @FXML private Button newGame; + @FXML private Button challenges; + @FXML private Button settings; + @FXML private Button statistics; + + @FXML + private void initialize() { + newGame.setOnAction(event -> getSudokuGui().changeScreenTo(NEW_GAME_MENU)); + challenges.setOnAction(event -> getSudokuGui().changeScreenTo(CHALLENGES)); + settings.setOnAction(event -> getSudokuGui().changeScreenTo(SETTINGS)); + statistics.setOnAction(event -> getSudokuGui().changeScreenTo(STATISTICS)); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/controller/NewGameMenuController.java b/src/main/java/ch/zhaw/pm2/amongdigits/controller/NewGameMenuController.java new file mode 100644 index 0000000..3fec8d7 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/controller/NewGameMenuController.java @@ -0,0 +1,163 @@ +package ch.zhaw.pm2.amongdigits.controller; + +import static ch.zhaw.pm2.amongdigits.DifficultyLevel.BEGINNER; +import static ch.zhaw.pm2.amongdigits.DifficultyLevel.EASY; +import static ch.zhaw.pm2.amongdigits.DifficultyLevel.EXPERT; +import static ch.zhaw.pm2.amongdigits.DifficultyLevel.HARD; +import static ch.zhaw.pm2.amongdigits.DifficultyLevel.MEDIUM; +import static ch.zhaw.pm2.amongdigits.ScreenType.MAIN_MENU; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.EMPTY_GRID_CELL; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.GRID_SEPARATOR; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.SUDOKU_GRID_SIZE; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.VALID_FILE_ENDING; +import static ch.zhaw.pm2.amongdigits.utils.schema.SchemaTypes.SCHEMA_9X9; +import static java.lang.String.format; +import static java.lang.System.lineSeparator; +import static javafx.scene.control.Alert.AlertType.ERROR; +import static javafx.scene.control.Alert.AlertType.INFORMATION; + +import ch.zhaw.pm2.amongdigits.ControlledScreen; +import ch.zhaw.pm2.amongdigits.DifficultyLevel; +import ch.zhaw.pm2.amongdigits.exception.InvalidFileFormatException; +import ch.zhaw.pm2.amongdigits.exception.InvalidSudokuException; +import ch.zhaw.pm2.amongdigits.upload.FileValidator; +import ch.zhaw.pm2.amongdigits.upload.SudokuFileLoader; +import ch.zhaw.pm2.amongdigits.utils.alert.AlertBuilder; +import ch.zhaw.pm2.amongdigits.utils.alert.AlertOptions; +import ch.zhaw.pm2.amongdigits.utils.sudoku.SudokuManager; +import java.io.File; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.ResourceBundle; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.image.Image; +import javafx.scene.layout.AnchorPane; +import javafx.stage.FileChooser; +import lombok.extern.slf4j.Slf4j; + +/** + * The NewGameMenuController class is the controller for the new game menu screen. It provides the + * user with options to start a new game with varying levels of difficulty, load a previously saved + * game or return to the main menu. + */ +@Slf4j +public class NewGameMenuController extends ControlledScreen { + + private static final String[] EXAMPLE_GRID = { + "9---8-3--", + "---25-7--", + "-2-3----4", + "-94------", + "---73-56-", + "7-5-6-4--", + "--78-39--", + "--1-----3", + "3-------2" + }; + private static final String GRID_HELP_TEXT = String.join(lineSeparator(), EXAMPLE_GRID); + + private final Map difficulties = new EnumMap<>(DifficultyLevel.class); + @FXML private ResourceBundle resources; + @FXML private AnchorPane rootPane; + @FXML private Button newBeginnerSudoku; + @FXML private Button newEasySudoku; + @FXML private Button newMediumSudoku; + @FXML private Button newHardSudoku; + @FXML private Button newExpertSudoku; + @FXML private Button loadSudoku; + @FXML private Button mainMenu; + private SudokuFileLoader sudokuFileLoader; + private FileChooser fileChooser; + + /** + * Initializes the NewGameMenuController. It creates the {@link SudokuFileLoader} and sets for + * each difficulty the event to be fired when a new game of that difficulty should initiate. + */ + @FXML + public void initialize() { + sudokuFileLoader = + new SudokuFileLoader( + new FileValidator(SUDOKU_GRID_SIZE, GRID_SEPARATOR, EMPTY_GRID_CELL), + new SudokuManager(SCHEMA_9X9), + resources); + fileChooser = new FileChooser(); + initFileChooser(); + difficulties.put(BEGINNER, newBeginnerSudoku); + difficulties.put(EASY, newEasySudoku); + difficulties.put(MEDIUM, newMediumSudoku); + difficulties.put(HARD, newHardSudoku); + difficulties.put(EXPERT, newExpertSudoku); + + mainMenu.setOnAction(event -> getSudokuGui().changeScreenTo(MAIN_MENU)); + loadSudoku.setOnAction(this::uploadSudoku); + difficulties.forEach( + (key, value) -> + value.setOnAction(event -> getSudokuGui().initializeNewGameWithDifficulty(key))); + } + + private void initFileChooser() { + fileChooser + .getExtensionFilters() + .add(new FileChooser.ExtensionFilter("Text Files", "*." + VALID_FILE_ENDING)); + fileChooser.setTitle(resources.getString("choose_file_title")); + } + + private void uploadSudoku(ActionEvent event) { + final File selectedFile = fileChooser.showOpenDialog(rootPane.getScene().getWindow()); + if (selectedFile != null) { + try { + sudokuFileLoader.uploadSudoku(selectedFile); + AlertBuilder.showAlert( + new AlertOptions( + INFORMATION, + resources.getString("upload_success"), + null, + resources.getString("upload_success_message"), + new Image("media/Upload.gif"), + Collections.emptySet())); + log.info("New sudoku " + selectedFile.getName() + " has been uploaded"); + getSudokuGui().reloadChallenges(); + } catch (final InvalidFileFormatException e) { + AlertBuilder.showAlert( + new AlertOptions( + ERROR, + resources.getString("upload_failed"), + null, + format(resources.getString("selected_file_invalid_exception"), e.getMessage()), + new Image("media/UploadFailed.gif"), + Collections.emptySet())); + log.warn( + "User tried to upload sudoku file with name %s but failed with exception %s" + .formatted(selectedFile.getName(), e.getMessage())); + } catch (InvalidSudokuException e) { + AlertBuilder.showAlert( + new AlertOptions( + ERROR, + resources.getString("upload_failed"), + null, + format( + resources.getString("selected_file_sudoku_invalid_exception"), e.getMessage()), + new Image("media/UploadFailed.gif"), + Collections.emptySet())); + log.warn( + "User tried to upload sudoku file with name %s but failed with exception %s" + .formatted(selectedFile.getName(), e.getMessage())); + } + } + } + + @FXML + private void displayHelp() { + AlertBuilder.showAlert( + new AlertOptions( + INFORMATION, + resources.getString("upload_help_title"), + null, + resources.getString("upload_help") + lineSeparator() + GRID_HELP_TEXT, + new Image("media/UploadHelp.gif"), + Collections.emptySet())); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/controller/SettingsController.java b/src/main/java/ch/zhaw/pm2/amongdigits/controller/SettingsController.java new file mode 100644 index 0000000..0590577 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/controller/SettingsController.java @@ -0,0 +1,82 @@ +package ch.zhaw.pm2.amongdigits.controller; + +import static ch.zhaw.pm2.amongdigits.ScreenType.MAIN_MENU; + +import ch.zhaw.pm2.amongdigits.ControlledScreen; +import ch.zhaw.pm2.amongdigits.model.SettingsModel; +import java.util.Locale; +import java.util.ResourceBundle; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ChoiceBox; +import javafx.util.StringConverter; + +/** + * The SettingsController class is responsible for controlling the settings screen of the Sudoku + * game. It allows the user to change the game's language, toggle dark mode, check mistakes, check + * time, and receive real-time feedback on their moves. + */ +public class SettingsController extends ControlledScreen { + + @FXML private ResourceBundle resources; + @FXML private Button mainMenu; + @FXML private ChoiceBox languageChooser = new ChoiceBox<>(); + @FXML private Button resetStatistics; + @FXML private Button toggleDarkMode; + @FXML private Button toggleCheckMistakes; + @FXML private Button toggleCheckTime; + @FXML private Button toggleRealtimeFeedback; + private SettingsModel settingsModel; + + /** + * Initializes the settings screen. It sets the action for the main menu button, initializes the + * language chooser, and binds the text for each setting to its corresponding property in the + * settings model. It also sets the action for each setting to its corresponding toggle method in + * the settings model. Finally, it sets the action for the reset statistics button and adds a + * listener for the dark mode value property. + */ + @FXML + public void initialize() { + mainMenu.setOnAction(event -> getSudokuGui().changeScreenTo(MAIN_MENU)); + initLanguageChooser(); + this.settingsModel = new SettingsModel(); + toggleDarkMode.textProperty().bind(settingsModel.getDarkModeProperty()); + toggleCheckMistakes.textProperty().bind(settingsModel.getCheckMistakesProperty()); + toggleCheckTime.textProperty().bind(settingsModel.getCheckTimeProperty()); + toggleRealtimeFeedback.textProperty().bind(settingsModel.getRealtimeFeedbackProperty()); + toggleDarkMode.setOnAction(event -> settingsModel.toggleDarkMode()); + toggleCheckMistakes.setOnAction(event -> settingsModel.toggleCheckMistakes()); + toggleCheckTime.setOnAction(event -> settingsModel.toggleCheckTime()); + toggleRealtimeFeedback.setOnAction(event -> settingsModel.toggleRealtimeFeedback()); + resetStatistics.setOnAction(event -> settingsModel.resetStatistics()); + settingsModel + .getDarkModeValueProperty() + .addListener((observable, oldValue, newValue) -> getSudokuGui().setStyleSheet(newValue)); + } + + private void initLanguageChooser() { + languageChooser.getItems().add(Locale.ENGLISH); + languageChooser.getItems().add(Locale.GERMAN); + + languageChooser.setValue(resources.getLocale()); + + languageChooser.setConverter( + new StringConverter<>() { + @Override + public String toString(Locale locale) { + return locale == null ? "" : locale.getDisplayName(locale); + } + + @Override + public Locale fromString(String string) { + return Locale.forLanguageTag(string); + } + }); + languageChooser.setOnAction( + event -> { + languageChooser.setValue(languageChooser.getSelectionModel().getSelectedItem()); + settingsModel.changeLanguage(languageChooser.getValue().toString()); + getSudokuGui().reloadScreens(); + }); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/controller/StatisticsController.java b/src/main/java/ch/zhaw/pm2/amongdigits/controller/StatisticsController.java new file mode 100644 index 0000000..03ad926 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/controller/StatisticsController.java @@ -0,0 +1,97 @@ +package ch.zhaw.pm2.amongdigits.controller; + +import static ch.zhaw.pm2.amongdigits.ScreenType.MAIN_MENU; + +import ch.zhaw.pm2.amongdigits.ControlledScreen; +import ch.zhaw.pm2.amongdigits.model.StatisticsModel; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; + +/** + * The StatisticsController class controls the behavior of the statistics screen in the AmongDigits + * game. It extends the ControlledScreen class and is responsible for initializing the labels with + * values from the StatisticsModel and binding them to the corresponding properties in the model. It + * also handles the event when the main menu button is clicked by changing the screen to the main + * menu. + */ +public class StatisticsController extends ControlledScreen { + + @FXML public Button mainMenu; + @FXML private Label veryEasyGameBestTime; + @FXML private Label veryEasyGameMistakes; + @FXML private Label veryEasyGameStarted; + @FXML private Label veryEasyGameTimePlayed; + @FXML private Label veryEasyGameWon; + @FXML private Label easyGameBestTime; + @FXML private Label easyGameMistakes; + @FXML private Label easyGameStarted; + @FXML private Label easyGameTimePlayed; + @FXML private Label easyGameWon; + @FXML private Label hardGameBestTime; + @FXML private Label hardGameMistakes; + @FXML private Label hardGameStarted; + @FXML private Label hardGameTimePlayed; + @FXML private Label hardGameWon; + @FXML private Label veryHardGameBestTime; + @FXML private Label veryHardGameMistakes; + @FXML private Label veryHardGameStarted; + @FXML private Label veryHardGameTimePlayed; + @FXML private Label veryHardGameWon; + @FXML private Label mediumGameBestTime; + @FXML private Label mediumGameMistakes; + @FXML private Label mediumGameStarted; + @FXML private Label mediumGameTimePlayed; + @FXML private Label mediumGameWon; + private StatisticsModel statisticsModel; + + /** + * Initializes the controller by setting the action of the main menu button to change the screen + * to the main menu. It also initializes the statisticsModel and binds the labels to the + * corresponding properties in the model. + */ + @FXML + public void initialize() { + mainMenu.setOnAction(event -> getSudokuGui().changeScreenTo(MAIN_MENU)); + this.statisticsModel = new StatisticsModel(); + bindLabels(); + } + + private void bindLabels() { + veryEasyGameWon.textProperty().bind(statisticsModel.beginnerGameWonPropertyProperty()); + veryEasyGameMistakes + .textProperty() + .bind(statisticsModel.beginnerGameMistakesPropertyProperty()); + veryEasyGameStarted.textProperty().bind(statisticsModel.beginnerGameStartedPropertyProperty()); + veryEasyGameBestTime + .textProperty() + .bind(statisticsModel.beginnerGameBestTimePropertyProperty()); + veryEasyGameTimePlayed + .textProperty() + .bind(statisticsModel.beginnerGameTimePlayedPropertyProperty()); + easyGameWon.textProperty().bind(statisticsModel.getEasyGameWonPropertyProperty()); + easyGameMistakes.textProperty().bind(statisticsModel.getEasyGameMistakesPropertyProperty()); + easyGameStarted.textProperty().bind(statisticsModel.getEasyGameStartedPropertyProperty()); + easyGameBestTime.textProperty().bind(statisticsModel.getEasyGameBestTimePropertyProperty()); + easyGameTimePlayed.textProperty().bind(statisticsModel.getEasyGameTimePlayedPropertyProperty()); + mediumGameStarted.textProperty().bind(statisticsModel.getMediumGameStartedPropertyProperty()); + mediumGameMistakes.textProperty().bind(statisticsModel.getMediumGameMistakesPropertyProperty()); + mediumGameWon.textProperty().bind(statisticsModel.getMediumGameWonPropertyProperty()); + mediumGameBestTime.textProperty().bind(statisticsModel.getMediumGameBestTimePropertyProperty()); + mediumGameTimePlayed + .textProperty() + .bind(statisticsModel.getMediumGameTimePlayedPropertyProperty()); + hardGameStarted.textProperty().bind(statisticsModel.getHardGameStartedPropertyProperty()); + hardGameMistakes.textProperty().bind(statisticsModel.getHardGameMistakesPropertyProperty()); + hardGameWon.textProperty().bind(statisticsModel.getHardGameWonPropertyProperty()); + hardGameBestTime.textProperty().bind(statisticsModel.getHardGameBestTimePropertyProperty()); + hardGameTimePlayed.textProperty().bind(statisticsModel.getHardGameTimePlayedPropertyProperty()); + veryHardGameStarted.textProperty().bind(statisticsModel.expertGameStartedPropertyProperty()); + veryHardGameMistakes.textProperty().bind(statisticsModel.expertGameMistakesPropertyProperty()); + veryHardGameWon.textProperty().bind(statisticsModel.expertGameWonPropertyProperty()); + veryHardGameBestTime.textProperty().bind(statisticsModel.expertGameBestTimePropertyProperty()); + veryHardGameTimePlayed + .textProperty() + .bind(statisticsModel.expertGameTimePlayedPropertyProperty()); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/controller/SudokuGameController.java b/src/main/java/ch/zhaw/pm2/amongdigits/controller/SudokuGameController.java new file mode 100644 index 0000000..c159903 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/controller/SudokuGameController.java @@ -0,0 +1,487 @@ +package ch.zhaw.pm2.amongdigits.controller; + +import static ch.zhaw.pm2.amongdigits.PropertyType.SETTINGS; +import static ch.zhaw.pm2.amongdigits.ScreenType.MAIN_MENU; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.getPropertyString; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.CLOCK_FORMAT; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.INFINITY_SYMBOL; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.SECOND_MINUTE_THRESHOLD; +import static java.lang.Boolean.TRUE; +import static java.lang.Integer.parseInt; +import static java.lang.String.format; +import static java.lang.String.valueOf; +import static javafx.scene.control.ButtonType.OK; +import static javafx.scene.input.KeyCode.BACK_SPACE; +import static javafx.scene.input.KeyCode.DELETE; +import static javafx.scene.paint.Color.GREEN; +import static javafx.scene.paint.Color.LIGHTBLUE; +import static javafx.scene.paint.Color.RED; +import static javafx.scene.paint.Color.TRANSPARENT; + +import ch.zhaw.pm2.amongdigits.ControlledScreen; +import ch.zhaw.pm2.amongdigits.DifficultyLevel; +import ch.zhaw.pm2.amongdigits.exception.InvalidFileFormatException; +import ch.zhaw.pm2.amongdigits.exception.InvalidSudokuException; +import ch.zhaw.pm2.amongdigits.model.SudokuGameModel; +import ch.zhaw.pm2.amongdigits.utils.alert.AlertBuilder; +import ch.zhaw.pm2.amongdigits.utils.alert.AlertOptions; +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.function.IntToDoubleFunction; +import javafx.beans.binding.Bindings; +import javafx.event.EventHandler; +import javafx.fxml.FXML; +import javafx.geometry.HPos; +import javafx.geometry.Pos; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.ToggleButton; +import javafx.scene.image.Image; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Background; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.text.Font; +import lombok.extern.slf4j.Slf4j; + +/** + * The SudokuGameController class is the controller for the Sudoku game. It is responsible for + * handling user input, updating the game state and GUI accordingly. + */ +@Slf4j +public class SudokuGameController extends ControlledScreen { + + private static final String REAL_TIME_FEEDBACK = "realtimeFeedback"; + private static final int SUDOKU_GRID_NUMBER = 9; + private static final int SUDOKU_INNER_GRID = 3; + private static final int FULL_SIZE = 1; + private static final String REGEX_ONE_DIGIT = "[1-9]"; + private static final IntToDoubleFunction RESOLVE_TOP_LEFT_BORDER = val -> val == 0 ? 4 : 0; + private static final IntToDoubleFunction RESOLVE_RIGHT_BOTTOM_BORDER = + val -> (val + 1) % 3 == 0 ? 4 : 0; + + private final Label[][] sudokuFields; + private final Label[][][][] pencilFields; + + private SudokuGameModel model; + private Label activeLabel; + + @FXML private ResourceBundle resources; + @FXML private AnchorPane rootPane; + @FXML private GridPane sudokuGrid; + @FXML private Label timeField; + @FXML private Label maxTimeField; + @FXML private Label mistakesField; + @FXML private Label maxMistakesField; + @FXML private ToggleButton pencilButton; + @FXML private HBox timeBox; + + /** + * This class controls the Sudoku game and initializes the game window. It creates the Sudoku grid + * and populates it with values. It also sets up listeners for the timer, mistakes, and game + * status. + */ + public SudokuGameController() { + sudokuFields = new Label[SUDOKU_GRID_NUMBER][SUDOKU_GRID_NUMBER]; + pencilFields = + new Label[SUDOKU_GRID_NUMBER][SUDOKU_GRID_NUMBER][SUDOKU_INNER_GRID][SUDOKU_INNER_GRID]; + } + + /** + * Initializes the game window by setting up the Sudoku grid, timer, mistakes, and game status. + * Listens for key presses and sets up the game if the Sudoku board is null. + */ + @FXML + public void initialize() { + model = new SudokuGameModel(resources); + setUpSudokuGrid(); + + mistakesField.textProperty().bind(model.getMistakesProperty().asString()); + if (getPropertyString(SETTINGS, "checkMistakes").equals("true")) { + maxMistakesField.textProperty().bind(model.getMaxMistakesProperty().asString()); + } else { + maxMistakesField.setText(INFINITY_SYMBOL); + } + + if (getPropertyString(SETTINGS, REAL_TIME_FEEDBACK).equals("false")) { + timeBox.setVisible(false); + } + + model + .getElapsedTimeProperty() + .addListener( + (observable, oldValue, newValue) -> { + int minutes = (newValue.intValue() / SECOND_MINUTE_THRESHOLD); + int seconds = (newValue.intValue() % SECOND_MINUTE_THRESHOLD); + + timeField.setText(format(CLOCK_FORMAT, minutes, seconds)); + }); + + if (getPropertyString(SETTINGS, "checkTime").equals("true")) { + model + .getTimeLimitProperty() + .addListener( + (observable, oldValue, newValue) -> { + int minutes = (newValue.intValue() / SECOND_MINUTE_THRESHOLD); + int seconds = (newValue.intValue() % SECOND_MINUTE_THRESHOLD); + + maxTimeField.setText(format(CLOCK_FORMAT, minutes, seconds)); + }); + } else { + maxTimeField.setText(INFINITY_SYMBOL); + } + + model + .isSolvedProperty() + .addListener( + (observable, oldValue, newValue) -> { + if (TRUE.equals(newValue)) { + displaySolvedDialog(); + } + }); + + model + .isLimitExceededProperty() + .addListener( + (observable, oldValue, newValue) -> { + if (TRUE.equals(newValue)) { + displayLostDialog(); + } + }); + + rootPane.setOnKeyPressed( + event -> { + if (event.getCode() == KeyCode.F1) { + displayHelp(); + } + }); + } + + /** + * Delegates the creation of a new sudoku based on the given {@link DifficultyLevel} + * + * @param difficultyLevel the {@link DifficultyLevel} for the Sudoku game to be created + */ + public void createSudoku(DifficultyLevel difficultyLevel) { + model.createSudoku(difficultyLevel); + fillInitialSudoku(); + model.startGame(); + } + + /** + * Delegates the creation of a new sudoku based on the given {@link File} + * + * @param sudokuFile the {@link File} which has the unsolved and solved grid of the Sudoku to be + * created + */ + public void createSudoku(File sudokuFile) + throws InvalidFileFormatException, InvalidSudokuException { + model.createSudoku(sudokuFile); + fillInitialSudoku(); + model.startGame(); + } + + private void fillInitialSudoku() { + for (int row = 0; row < SUDOKU_GRID_NUMBER; row++) { + for (int column = 0; column < SUDOKU_GRID_NUMBER; column++) { + String numberValue = + (model.getSudokuBoard().unsolvedGrid()[row][column] == 0) + ? "" + : valueOf(model.getSudokuBoard().unsolvedGrid()[row][column]); + sudokuFields[row][column].setText(numberValue); + } + } + } + + @FXML + private void exit() { + model.stopGame(); + getSudokuGui().changeScreenTo(MAIN_MENU); + } + + @FXML + private void displayHelp() { + AlertBuilder.showAlert( + new AlertOptions( + AlertType.INFORMATION, + resources.getString("help_title"), + null, + resources.getString("help_text"), + new Image("media/Help.gif"), + Collections.emptySet())); + } + + @FXML + private void displaySolvedDialog() { + Optional buttonType = + AlertBuilder.showAlert( + new AlertOptions( + AlertType.INFORMATION, + resources.getString("won_title"), + resources.getString("won_text"), + null, + new Image("media/GameWon.gif"), + Collections.emptySet())); + if (buttonType.isPresent() && buttonType.get() == OK) { + model.stopGame(); + getSudokuGui().changeScreenTo(MAIN_MENU); + } + } + + @FXML + private void displayLostDialog() { + Optional buttonType = + AlertBuilder.showAlert( + new AlertOptions( + AlertType.INFORMATION, + resources.getString("lost_title"), + resources.getString("lost_text"), + null, + new Image("media/GameLost.gif"), + Set.of( + new ButtonType(resources.getString("retry_game"), ButtonBar.ButtonData.APPLY), + new ButtonType(resources.getString("main_menu"), OK.getButtonData())))); + + if (buttonType.isPresent()) { + if (buttonType.get().getButtonData() == ButtonBar.ButtonData.APPLY) { + model.stopGame(); + resetGame(); + model.startGame(); + + } else { + model.stopGame(); + getSudokuGui().changeScreenTo(MAIN_MENU); + } + } + } + + private void resetGame() { + deactivateLabel(activeLabel); + + for (int row = 0; row < SUDOKU_GRID_NUMBER; row++) { + for (int col = 0; col < SUDOKU_GRID_NUMBER; col++) { + sudokuFields[row][col].setText(""); + for (int subRow = 0; subRow < SUDOKU_INNER_GRID; subRow++) { + for (int subCol = 0; subCol < SUDOKU_INNER_GRID; subCol++) { + pencilFields[row][col][subRow][subCol].setText(""); + } + } + } + } + fillInitialSudoku(); + } + + @FXML + private void switchInputMode() { + if (activeLabel != null) { + activeLabel.setOnKeyPressed(numKeyPressed(activeLabel)); + } + } + + private void setUpSudokuGrid() { + sudokuGrid.setAlignment(Pos.CENTER); + populateGrid(sudokuGrid, SUDOKU_GRID_NUMBER); + for (int row = 0; row < SUDOKU_GRID_NUMBER; row++) { + for (int col = 0; col < SUDOKU_GRID_NUMBER; col++) { + final GridPane subGridPane = new GridPane(); + initializeSubGrid(subGridPane, row, col); + populateGrid(subGridPane, SUDOKU_INNER_GRID); + addSubGridChildren(subGridPane, row, col); + final Label numLabel = createNumLabel(subGridPane, row, col); + sudokuFields[row][col] = numLabel; + sudokuGrid.add(subGridPane, col, row); + sudokuGrid.add(numLabel, col, row); + GridPane.setHalignment(numLabel, HPos.CENTER); + } + } + } + + private void populateGrid(final GridPane gridPane, final int dimension) { + for (int i = 0; i < dimension; i++) { + gridPane.addColumn(i); + gridPane.addRow(i); + } + } + + private void initializeSubGrid(final GridPane subGridPane, final int row, final int col) { + subGridPane.setId("subGrid-" + col + row); + + subGridPane.maxHeightProperty().bind(sudokuGrid.widthProperty().divide(SUDOKU_GRID_NUMBER)); + subGridPane.maxWidthProperty().bind(sudokuGrid.heightProperty().divide(SUDOKU_GRID_NUMBER)); + + subGridPane + .prefWidthProperty() + .bind( + Bindings.min( + rootPane + .widthProperty() + .divide(SUDOKU_GRID_NUMBER) + .subtract(SUDOKU_INNER_GRID * subGridPane.getHgap()), + rootPane + .heightProperty() + .divide(SUDOKU_GRID_NUMBER) + .subtract(SUDOKU_INNER_GRID * subGridPane.getVgap()))); + subGridPane.prefHeightProperty().bind(subGridPane.prefWidthProperty()); + subGridPane.setAlignment(Pos.CENTER); + subGridPane + .vgapProperty() + .bind( + Bindings.min( + rootPane.heightProperty().divide(SUDOKU_GRID_NUMBER * SUDOKU_GRID_NUMBER * 2), + rootPane.widthProperty().divide(SUDOKU_GRID_NUMBER * SUDOKU_GRID_NUMBER * 2))); + subGridPane.hgapProperty().bind(subGridPane.vgapProperty()); + } + + private void addSubGridChildren(final GridPane subGridPane, final int row, final int col) { + for (int subRow = 0; subRow < SUDOKU_INNER_GRID; subRow++) { + for (int subCol = 0; subCol < SUDOKU_INNER_GRID; subCol++) { + final Label subNumLabel = new Label(); + subNumLabel.setId("num" + row + col + "-pencilNum-" + subRow + subCol); + subGridPane + .widthProperty() + .addListener( + (obs, oldWidth, newWidth) -> + updateLabelFontSize(subNumLabel, subGridPane, SUDOKU_INNER_GRID)); + subGridPane + .heightProperty() + .addListener( + (obs, oldHeight, newHeight) -> + updateLabelFontSize(subNumLabel, subGridPane, SUDOKU_INNER_GRID)); + pencilFields[row][col][subRow][subCol] = subNumLabel; + subGridPane.add(subNumLabel, subCol, subRow); + GridPane.setHalignment(subNumLabel, HPos.CENTER); + } + } + } + + private Label createNumLabel(GridPane subGridPane, int row, int col) { + final Label numLabel = new Label(); + numLabel.setId("num-" + row + "-" + col); + numLabel.setStyle(defineBorder(row, col)); + numLabel.setAlignment(Pos.CENTER); + numLabel.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + + subGridPane + .widthProperty() + .addListener( + (obs, oldWidth, newWidth) -> updateLabelFontSize(numLabel, subGridPane, FULL_SIZE)); + subGridPane + .heightProperty() + .addListener( + (obs, oldHeight, newHeight) -> updateLabelFontSize(numLabel, subGridPane, FULL_SIZE)); + + numLabel.setOnMouseClicked(event -> activateLabel(numLabel)); + return numLabel; + } + + // Method to update the font size of the label based on the cell dimensions + private void updateLabelFontSize(Label label, GridPane gridPane, int gridSize) { + double cellWidth = gridPane.getWidth(); + double cellHeight = gridPane.getHeight(); + + double fontSize = + Math.min((cellWidth / label.getText().length() / gridSize), (cellHeight / (gridSize * 2))); + label.setFont(Font.font(fontSize)); + } + + private String defineBorder(final int row, final int col) { + return "-fx-border-width: " + + RESOLVE_TOP_LEFT_BORDER.applyAsDouble(row) + + " " + + RESOLVE_RIGHT_BOTTOM_BORDER.applyAsDouble(col) + + " " + + RESOLVE_RIGHT_BOTTOM_BORDER.applyAsDouble(row) + + " " + + RESOLVE_TOP_LEFT_BORDER.applyAsDouble(col) + + ";"; + } + + private void activateLabel(Label label) { + // Only activate label if it is not pre-set + if (!model.isDefaultNumber( + getNumberLabelPosition(label)[0], getNumberLabelPosition(label)[1])) { + + if (activeLabel != null) { + activeLabel.setBackground(Background.fill(TRANSPARENT)); + activeLabel.setOpacity(1); + rootPane.getScene().setOnKeyPressed(null); + } + activeLabel = label; + activeLabel.setBackground(Background.fill(LIGHTBLUE)); + activeLabel.setOpacity(0.5); + rootPane.getScene().setOnKeyPressed(numKeyPressed(activeLabel)); + } + } + + private void deactivateLabel(Label label) { + if (label.equals(activeLabel)) { + activeLabel = null; + } + + label.setBackground(Background.fill(TRANSPARENT)); + label.setOnKeyPressed(null); + } + + private EventHandler numKeyPressed(final Label label) { + return event -> { + if (event.getCode() == BACK_SPACE || event.getCode() == DELETE) { + label.setText(""); + deactivateLabel(label); + clearSubPencilLabels(label); + } else if (event.getText().matches(REGEX_ONE_DIGIT)) { + validateInputBasedOnPencilState(label, event); + } + }; + } + + private void validateInputBasedOnPencilState(Label label, KeyEvent event) { + if (pencilButton.isSelected()) { + processSubLabels(label, parseInt(event.getText())); + } else { + label.setText(event.getText()); + if (model.checkInput( + Byte.parseByte(event.getText()), + getNumberLabelPosition(label)[0], + getNumberLabelPosition(label)[1]) + && getPropertyString(SETTINGS, REAL_TIME_FEEDBACK).equals("true")) { + label.setTextFill(GREEN); + } else if (getPropertyString(SETTINGS, REAL_TIME_FEEDBACK).equals("true")) { + label.setTextFill(RED); + } + clearSubPencilLabels(label); + } + } + + private void clearSubPencilLabels(Label label) { + Arrays.stream(getSubLabels(label)) + .flatMap(Arrays::stream) + .forEach(subPencilLabel -> subPencilLabel.setText("")); + } + + private void processSubLabels(final Label label, final int numKey) { + final Label[][] subLabels = getSubLabels(label); + label.setText(""); + int cellIndex = numKey - 1; + int row = cellIndex / 3; + int col = cellIndex % 3; + subLabels[row][col].setText(subLabels[row][col].getText().isBlank() ? valueOf(numKey) : ""); + } + + private Label[][] getSubLabels(final Label parentLabel) { + final int[] labelPositions = getNumberLabelPosition(parentLabel); + return pencilFields[labelPositions[0]][labelPositions[1]].clone(); + } + + private int[] getNumberLabelPosition(final Label label) { + final String[] labelPositions = label.getId().split("-"); + return new int[] {parseInt(labelPositions[1]), parseInt(labelPositions[2])}; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/exception/InvalidFileFormatException.java b/src/main/java/ch/zhaw/pm2/amongdigits/exception/InvalidFileFormatException.java new file mode 100644 index 0000000..2a7b307 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/exception/InvalidFileFormatException.java @@ -0,0 +1,24 @@ +package ch.zhaw.pm2.amongdigits.exception; + +/** Exception thrown when attempting to read a file with an invalid format. */ +public class InvalidFileFormatException extends Exception { + + /** + * Constructs a new InvalidFileFormatException with the specified detail message. + * + * @param message the detail message. + */ + public InvalidFileFormatException(final String message) { + super(message); + } + + /** + * Constructs a new InvalidFileFormatException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause of the exception. + */ + public InvalidFileFormatException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/exception/InvalidSudokuException.java b/src/main/java/ch/zhaw/pm2/amongdigits/exception/InvalidSudokuException.java new file mode 100644 index 0000000..6d3a398 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/exception/InvalidSudokuException.java @@ -0,0 +1,14 @@ +package ch.zhaw.pm2.amongdigits.exception; + +/** An exception thrown when a Sudoku puzzle is invalid. */ +public class InvalidSudokuException extends Exception { + + /** + * Constructs an InvalidSudokuException with the specified detail message. + * + * @param message The detail message. + */ + public InvalidSudokuException(String message) { + super(message); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/model/ChallengesModel.java b/src/main/java/ch/zhaw/pm2/amongdigits/model/ChallengesModel.java new file mode 100644 index 0000000..d24e6d8 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/model/ChallengesModel.java @@ -0,0 +1,50 @@ +package ch.zhaw.pm2.amongdigits.model; + +import static java.util.Objects.requireNonNull; + +import ch.zhaw.pm2.amongdigits.ChallengeType; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * The ChallengesModel class represents a model for Sudoku challenges. It provides methods to load + * challenges of different types from their corresponding directories and store them in a map. + */ +public class ChallengesModel { + + private final Map> challenges = new EnumMap<>(ChallengeType.class); + + /** + * Loads challenges of different types from their corresponding directories and stores them in the + * map. + */ + public void load() { + Arrays.stream(ChallengeType.values()) + .forEach( + challengeType -> challenges.put(challengeType, getSudokuChallenges(challengeType))); + } + + /** + * Returns the map that stores all the Sudoku challenges of different types. + * + * @return A map that stores challenges of different types. + */ + public Map> getChallenges() { + return challenges; + } + + private List getSudokuChallenges(final ChallengeType challengeType) { + final File dir = + new File( + requireNonNull(getClass().getClassLoader().getResource(challengeType.getDirectory())) + .getFile()); + if (!dir.isDirectory() || !dir.exists()) { + return new ArrayList<>(); + } + return Arrays.asList(requireNonNull(dir.listFiles())); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/model/SettingsModel.java b/src/main/java/ch/zhaw/pm2/amongdigits/model/SettingsModel.java new file mode 100644 index 0000000..c234682 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/model/SettingsModel.java @@ -0,0 +1,205 @@ +package ch.zhaw.pm2.amongdigits.model; + +import static ch.zhaw.pm2.amongdigits.PropertyType.SETTINGS; +import static ch.zhaw.pm2.amongdigits.PropertyType.STATISTICS; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.*; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.DISABLED_SYMBOL; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.ENABLED_SYMBOL; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Objects; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * The SettingsModel class represents the model of the settings for the Sudoku game. It provides + * methods to toggle different settings, update the corresponding properties and retrieve their + * values. It also initializes the settings based on the stored configuration file. + */ +@Slf4j +public class SettingsModel { + + private static final String DARK_MODE_STRING = "darkMode"; + private static final String FALSE = "false"; + private static final String TRUE = "true"; + private final SimpleStringProperty darkModeProperty = new SimpleStringProperty(); + private final SimpleStringProperty checkMistakesProperty = new SimpleStringProperty(); + private final SimpleStringProperty checkTimeProperty = new SimpleStringProperty(); + private final SimpleStringProperty realtimeFeedbackProperty = new SimpleStringProperty(); + private final SimpleStringProperty darkModeValueProperty = new SimpleStringProperty(); + + /** + * Creates a new instance of SettingsModel and initializes the settings based on the stored + * configuration file. + */ + public SettingsModel() { + initializeSettings(); + } + + /** Toggles the dark mode setting and updates the corresponding properties. */ + public void toggleDarkMode() { + if (getSettingsPropertiesString(DARK_MODE_STRING).equals(FALSE)) { + updateSettingsPropertiesString(DARK_MODE_STRING, TRUE); + } else { + updateSettingsPropertiesString(DARK_MODE_STRING, FALSE); + } + updateDarkModeProperties(); + } + + /** Toggles the check mistakes setting and updates the corresponding property. */ + public void toggleCheckMistakes() { + String mistakes = "checkMistakes"; + if (getSettingsPropertiesString(mistakes).equals(TRUE)) { + updateSettingsPropertiesString(mistakes, FALSE); + } else { + updateSettingsPropertiesString(mistakes, TRUE); + } + updateCheckMistakesProperty(); + } + + /** Toggles the check time setting. */ + public void toggleCheckTime() { + String time = "checkTime"; + if (getSettingsPropertiesString(time).equals(TRUE)) { + updateSettingsPropertiesString(time, FALSE); + } else { + updateSettingsPropertiesString(time, TRUE); + } + updateCheckTimeProperty(); + } + + /** Toggles the realtime feedback setting. */ + public void toggleRealtimeFeedback() { + String realtimeFeedback = "realtimeFeedback"; + if (getSettingsPropertiesString(realtimeFeedback).equals(TRUE)) { + updateSettingsPropertiesString(realtimeFeedback, FALSE); + } else { + updateSettingsPropertiesString(realtimeFeedback, TRUE); + } + updateRealtimeFeedbackProperty(); + } + + /** Resets the statistics. */ + public void resetStatistics() { + try { + Files.copy( + Objects.requireNonNull( + getClass().getResourceAsStream("/properties/defaultStatistics.properties")), + new File(STATISTICS.getFileName()).toPath(), + REPLACE_EXISTING); + } catch (IOException e) { + log.error(e.getMessage()); + } + } + + /** + * Changes the language. + * + * @param newLanguage the new language + */ + public void changeLanguage(String newLanguage) { + String language = "language"; + updateSettingsPropertiesString(language, newLanguage); + } + + /** + * Gets the dark mode property. + * + * @return the dark mode property + */ + public StringProperty getDarkModeProperty() { + return darkModeProperty; + } + + /** + * Gets the check mistakes property. + * + * @return the check mistakes property + */ + public StringProperty getCheckMistakesProperty() { + return checkMistakesProperty; + } + + /** + * Gets the check time property. + * + * @return the check time property + */ + public StringProperty getCheckTimeProperty() { + return checkTimeProperty; + } + + /** + * Gets the realtime feedback property. + * + * @return the realtime feedback property + */ + public StringProperty getRealtimeFeedbackProperty() { + return realtimeFeedbackProperty; + } + + /** + * Gets the dark mode value property. + * + * @return the dark mode value property + */ + public StringProperty getDarkModeValueProperty() { + return darkModeValueProperty; + } + + private void updateDarkModeProperties() { + String cssFile; + if (getSettingsPropertiesString(DARK_MODE_STRING).equals(TRUE)) { + darkModeProperty.setValue(ENABLED_SYMBOL); + cssFile = "css/darkMode.css"; + } else { + darkModeProperty.setValue(DISABLED_SYMBOL); + cssFile = "css/lightMode.css"; + } + updatePropertyString(SETTINGS, "cssFileString", "/" + cssFile); + darkModeValueProperty.setValue(cssFile); + } + + private void updateCheckMistakesProperty() { + if (getSettingsPropertiesString("checkMistakes").equals("true")) { + checkMistakesProperty.setValue(ENABLED_SYMBOL); + } else { + checkMistakesProperty.setValue(DISABLED_SYMBOL); + } + } + + private void updateCheckTimeProperty() { + if (getSettingsPropertiesString("checkTime").equals("true")) { + checkTimeProperty.setValue(ENABLED_SYMBOL); + } else { + checkTimeProperty.setValue(DISABLED_SYMBOL); + } + } + + private void updateRealtimeFeedbackProperty() { + if (getSettingsPropertiesString("realtimeFeedback").equals("true")) { + realtimeFeedbackProperty.setValue(ENABLED_SYMBOL); + } else { + realtimeFeedbackProperty.setValue(DISABLED_SYMBOL); + } + } + + private void initializeSettings() { + updateDarkModeProperties(); + updateCheckMistakesProperty(); + updateCheckTimeProperty(); + updateRealtimeFeedbackProperty(); + } + + private String getSettingsPropertiesString(String key) { + return getPropertyString(SETTINGS, key); + } + + private void updateSettingsPropertiesString(String key, String value) { + updatePropertyString(SETTINGS, key, value); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/model/StatisticsModel.java b/src/main/java/ch/zhaw/pm2/amongdigits/model/StatisticsModel.java new file mode 100644 index 0000000..3093c38 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/model/StatisticsModel.java @@ -0,0 +1,265 @@ +package ch.zhaw.pm2.amongdigits.model; + +import javafx.beans.property.SimpleStringProperty; +import java.util.Properties; +import static ch.zhaw.pm2.amongdigits.PropertyType.STATISTICS; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.loadProperties; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.storeProperties; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.CLOCK_FORMAT; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.EMPTY_CLOCK_FORMAT; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.SECOND_MINUTE_THRESHOLD; +import static java.lang.Integer.parseInt; + +/** The StatisticsModel class is responsible for the statistics of the game. */ +public class StatisticsModel { + + private final SimpleStringProperty beginnerGameStartedProperty = new SimpleStringProperty(); + private final SimpleStringProperty beginnerGameWonProperty = new SimpleStringProperty(); + private final SimpleStringProperty beginnerGameMistakesProperty = new SimpleStringProperty(); + private final SimpleStringProperty beginnerGameTimePlayedProperty = new SimpleStringProperty(); + private final SimpleStringProperty beginnerGameBestTimeProperty = new SimpleStringProperty(); + private final SimpleStringProperty easyGameStartedProperty = new SimpleStringProperty(); + private final SimpleStringProperty easyGameWonProperty = new SimpleStringProperty(); + private final SimpleStringProperty easyGameMistakesProperty = new SimpleStringProperty(); + private final SimpleStringProperty easyGameTimePlayedProperty = new SimpleStringProperty(); + private final SimpleStringProperty easyGameBestTimeProperty = new SimpleStringProperty(); + private final SimpleStringProperty mediumGameStartedProperty = new SimpleStringProperty(); + private final SimpleStringProperty mediumGameWonProperty = new SimpleStringProperty(); + private final SimpleStringProperty mediumGameMistakesProperty = new SimpleStringProperty(); + private final SimpleStringProperty mediumGameTimePlayedProperty = new SimpleStringProperty(); + private final SimpleStringProperty mediumGameBestTimeProperty = new SimpleStringProperty(); + private final SimpleStringProperty hardGameStartedProperty = new SimpleStringProperty(); + private final SimpleStringProperty hardGameWonProperty = new SimpleStringProperty(); + private final SimpleStringProperty hardGameMistakesProperty = new SimpleStringProperty(); + private final SimpleStringProperty hardGameTimePlayedProperty = new SimpleStringProperty(); + private final SimpleStringProperty hardGameBestTimeProperty = new SimpleStringProperty(); + private final SimpleStringProperty expertGameStartedProperty = new SimpleStringProperty(); + private final SimpleStringProperty expertGameWonProperty = new SimpleStringProperty(); + private final SimpleStringProperty expertGameMistakesProperty = new SimpleStringProperty(); + private final SimpleStringProperty expertGameTimePlayedProperty = new SimpleStringProperty(); + private final SimpleStringProperty expertGameBestTimeProperty = new SimpleStringProperty(); + + public StatisticsModel() { + updateStatistics(); + } + + private void updateStatistics() { + Properties statistics = loadProperties(STATISTICS); + beginnerGameMistakesProperty.set(statistics.getProperty("beginnerGameMistakes")); + beginnerGameStartedProperty.set(statistics.getProperty("beginnerGameStarted")); + beginnerGameBestTimeProperty.set( + formatTime(parseInt(statistics.getProperty("beginnerGameBestTime")))); + beginnerGameTimePlayedProperty.set(statistics.getProperty("beginnerGameTimePlayed")); + beginnerGameWonProperty.set(statistics.getProperty("beginnerGameWon")); + easyGameMistakesProperty.set(statistics.getProperty("easyGameMistakes")); + easyGameStartedProperty.set(statistics.getProperty("easyGameStarted")); + easyGameBestTimeProperty.set(formatTime(parseInt(statistics.getProperty("easyGameBestTime")))); + easyGameTimePlayedProperty.set(statistics.getProperty("easyGameTimePlayed")); + easyGameWonProperty.set(statistics.getProperty("easyGameWon")); + mediumGameMistakesProperty.set(statistics.getProperty("mediumGameMistakes")); + mediumGameStartedProperty.set(statistics.getProperty("mediumGameStarted")); + mediumGameBestTimeProperty.set( + formatTime(parseInt(statistics.getProperty("mediumGameBestTime")))); + mediumGameTimePlayedProperty.set(statistics.getProperty("mediumGameTimePlayed")); + mediumGameWonProperty.set(statistics.getProperty("mediumGameWon")); + hardGameMistakesProperty.set(statistics.getProperty("hardGameMistakes")); + hardGameStartedProperty.set(statistics.getProperty("hardGameStarted")); + hardGameBestTimeProperty.set(formatTime(parseInt(statistics.getProperty("hardGameBestTime")))); + hardGameTimePlayedProperty.set(statistics.getProperty("hardGameTimePlayed")); + hardGameWonProperty.set(statistics.getProperty("hardGameWon")); + expertGameMistakesProperty.set(statistics.getProperty("expertGameMistakes")); + expertGameStartedProperty.set(statistics.getProperty("expertGameStarted")); + expertGameBestTimeProperty.set( + formatTime(parseInt(statistics.getProperty("expertGameBestTime")))); + expertGameTimePlayedProperty.set(statistics.getProperty("expertGameTimePlayed")); + expertGameWonProperty.set(statistics.getProperty("expertGameWon")); + storeProperties(STATISTICS, statistics); + } + + /** + * The beginnerGameStartedPropertyProperty function returns the beginnerGameStartedProperty + * property. + */ + public SimpleStringProperty beginnerGameStartedPropertyProperty() { + return beginnerGameStartedProperty; + } + + /** The beginnerGameWonPropertyProperty function returns the beginnerGameWonProperty property. */ + public SimpleStringProperty beginnerGameWonPropertyProperty() { + return beginnerGameWonProperty; + } + + /** + * The beginnerGameMistakesPropertyProperty function returns the beginnerGameMistakesProperty + * property. + */ + public SimpleStringProperty beginnerGameMistakesPropertyProperty() { + return beginnerGameMistakesProperty; + } + + /** + * The beginnerGameTimePlayedPropertyProperty function returns the beginnerGameTimePlayedProperty + * property. + */ + public SimpleStringProperty beginnerGameTimePlayedPropertyProperty() { + return beginnerGameTimePlayedProperty; + } + + /** + * The beginnerGameBestTimePropertyProperty function returns the beginnerGameBestTimeProperty + * property. + */ + public SimpleStringProperty beginnerGameBestTimePropertyProperty() { + return beginnerGameBestTimeProperty; + } + + /** + * The getEasyGameStartedPropertyProperty function returns the easyGameStartedProperty property. + */ + public SimpleStringProperty getEasyGameStartedPropertyProperty() { + return easyGameStartedProperty; + } + + /** The getEasyGameWonPropertyProperty function returns the easyGameWonProperty property. */ + public SimpleStringProperty getEasyGameWonPropertyProperty() { + return easyGameWonProperty; + } + + /** + * The getEasyGameMistakesPropertyProperty function returns the easyGameMistakesProperty property. + */ + public SimpleStringProperty getEasyGameMistakesPropertyProperty() { + return easyGameMistakesProperty; + } + + /** + * The getEasyGameTimePlayedPropertyProperty function returns the easyGameTimePlayedProperty + * property. + */ + public SimpleStringProperty getEasyGameTimePlayedPropertyProperty() { + return easyGameTimePlayedProperty; + } + + /** + * The getEasyGameBestTimePropertyProperty function returns the easyGameBestTimeProperty property. + */ + public SimpleStringProperty getEasyGameBestTimePropertyProperty() { + return easyGameBestTimeProperty; + } + + /** + * The getMediumGameStartedPropertyProperty function returns the mediumGameStartedProperty + * property. + */ + public SimpleStringProperty getMediumGameStartedPropertyProperty() { + return mediumGameStartedProperty; + } + + /** The getMediumGameWonPropertyProperty function returns the mediumGameWonProperty property. */ + public SimpleStringProperty getMediumGameWonPropertyProperty() { + return mediumGameWonProperty; + } + + /** + * The getMediumGameMistakesPropertyProperty function returns the mediumGameMistakesProperty + * property. + */ + public SimpleStringProperty getMediumGameMistakesPropertyProperty() { + return mediumGameMistakesProperty; + } + + /** + * The getMediumGameTimePlayedPropertyProperty function returns the mediumGameTimePlayedProperty + * property. + */ + public SimpleStringProperty getMediumGameTimePlayedPropertyProperty() { + return mediumGameTimePlayedProperty; + } + + /** + * The getMediumGameBestTimePropertyProperty function returns the mediumGameBestTimeProperty + * property. + */ + public SimpleStringProperty getMediumGameBestTimePropertyProperty() { + return mediumGameBestTimeProperty; + } + + /** + * The getHardGameStartedPropertyProperty function returns the hardGameStartedProperty property. + */ + public SimpleStringProperty getHardGameStartedPropertyProperty() { + return hardGameStartedProperty; + } + + /** The getHardGameWonPropertyProperty function returns the hardGameWonProperty property. */ + public SimpleStringProperty getHardGameWonPropertyProperty() { + return hardGameWonProperty; + } + + /** + * The getHardGameMistakesPropertyProperty function returns the hardGameMistakesProperty property. + */ + public SimpleStringProperty getHardGameMistakesPropertyProperty() { + return hardGameMistakesProperty; + } + + /** + * The getHardGameTimePlayedPropertyProperty function returns the hardGameTimePlayedProperty + * property. + */ + public SimpleStringProperty getHardGameTimePlayedPropertyProperty() { + return hardGameTimePlayedProperty; + } + + /** + * The getHardGameBestTimePropertyProperty function returns the hardGameBestTimeProperty property. + */ + public SimpleStringProperty getHardGameBestTimePropertyProperty() { + return hardGameBestTimeProperty; + } + + /** + * The expertGameStartedPropertyProperty function returns the expertGameStartedProperty property. + */ + public SimpleStringProperty expertGameStartedPropertyProperty() { + return expertGameStartedProperty; + } + + /** The expertGameWonPropertyProperty function returns the expertGameWonProperty property. */ + public SimpleStringProperty expertGameWonPropertyProperty() { + return expertGameWonProperty; + } + + /** + * The expertGameMistakesPropertyProperty function returns the expertGameMistakesProperty + * property. + */ + public SimpleStringProperty expertGameMistakesPropertyProperty() { + return expertGameMistakesProperty; + } + + /** + * The expertGameTimePlayedPropertyProperty function returns the expertGameTimePlayedProperty + * property. + */ + public SimpleStringProperty expertGameTimePlayedPropertyProperty() { + return expertGameTimePlayedProperty; + } + + /** + * The expertGameBestTimePropertyProperty function returns the expertGameBestTimeProperty + * property. + */ + public SimpleStringProperty expertGameBestTimePropertyProperty() { + return expertGameBestTimeProperty; + } + + private String formatTime(int timeInSeconds) { + if (timeInSeconds == 0) { + return EMPTY_CLOCK_FORMAT; + } + int seconds = timeInSeconds % SECOND_MINUTE_THRESHOLD; + int minutes = timeInSeconds / SECOND_MINUTE_THRESHOLD; + return String.format(CLOCK_FORMAT, minutes, seconds); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/model/SudokuGameModel.java b/src/main/java/ch/zhaw/pm2/amongdigits/model/SudokuGameModel.java new file mode 100644 index 0000000..c05e205 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/model/SudokuGameModel.java @@ -0,0 +1,300 @@ +package ch.zhaw.pm2.amongdigits.model; + +import static ch.zhaw.pm2.amongdigits.PropertyType.SETTINGS; +import static ch.zhaw.pm2.amongdigits.PropertyType.STATISTICS; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.getPropertyString; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.updatePropertyString; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.EMPTY_GRID_CELL; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.GRID_SEPARATOR; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.SUDOKU_GRID_SIZE; +import static java.lang.Integer.parseInt; + +import ch.zhaw.pm2.amongdigits.DifficultyLevel; +import ch.zhaw.pm2.amongdigits.SudokuBoard; +import ch.zhaw.pm2.amongdigits.exception.InvalidFileFormatException; +import ch.zhaw.pm2.amongdigits.exception.InvalidSudokuException; +import ch.zhaw.pm2.amongdigits.upload.FileValidator; +import ch.zhaw.pm2.amongdigits.upload.SudokuFileLoader; +import ch.zhaw.pm2.amongdigits.utils.Creator; +import ch.zhaw.pm2.amongdigits.utils.Solver; +import ch.zhaw.pm2.amongdigits.utils.matrix.Matrix; +import ch.zhaw.pm2.amongdigits.utils.schema.SchemaTypes; +import ch.zhaw.pm2.amongdigits.utils.sudoku.Sudoku; +import ch.zhaw.pm2.amongdigits.utils.sudoku.SudokuManager; +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.ResourceBundle; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +import javafx.animation.AnimationTimer; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleLongProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * The SudokuGameModel class represents the model of the Sudoku game. It provides the necessary + * functionality for loading a Sudoku board from a file, setting up the game board, solving the + * Sudoku, and managing the game's state. + * + * @author ch.zhaw.pm2.amongdigits.model + */ +@Slf4j +public class SudokuGameModel { + + private final SudokuFileLoader sudokuFileLoader; + + private final IntegerProperty mistakes; + private final IntegerProperty maxMistakes; + private final LongProperty elapsedTime; + private final LongProperty timeLimit; + private final BooleanProperty isSolved; + private final BooleanProperty isLimitExceeded; + + private SudokuBoard sudokuBoard; + private byte[][] currentGrid; + private AnimationTimer timer; + + /** + * Constructs a new SudokuGameModel object with the given ResourceBundle. + * + * @param resources the ResourceBundle used for loading the Sudoku game file and for localization + */ + public SudokuGameModel(ResourceBundle resources) { + mistakes = new SimpleIntegerProperty(); + maxMistakes = new SimpleIntegerProperty(); + elapsedTime = new SimpleLongProperty(System.nanoTime()); + timeLimit = new SimpleLongProperty(System.nanoTime()); + isSolved = new SimpleBooleanProperty(); + isLimitExceeded = new SimpleBooleanProperty(); + + sudokuFileLoader = + new SudokuFileLoader( + new FileValidator(SUDOKU_GRID_SIZE, GRID_SEPARATOR, EMPTY_GRID_CELL), + new SudokuManager(SchemaTypes.SCHEMA_9X9), + resources); + } + + /** + * Returns the current Sudoku board. + * + * @return the current Sudoku board + */ + public SudokuBoard getSudokuBoard() { + return sudokuBoard; + } + + /** + * Returns the integer property of the number of mistakes made. + * + * @return the integer property of the number of mistakes made + */ + public IntegerProperty getMistakesProperty() { + return mistakes; + } + + /** + * Returns the integer property of the maximum number of mistakes allowed. + * + * @return the integer property of the maximum number of mistakes allowed + */ + public IntegerProperty getMaxMistakesProperty() { + return maxMistakes; + } + + /** + * Returns the long property of the elapsed time since the game started. + * + * @return the long property of the elapsed time since the game started + */ + public LongProperty getElapsedTimeProperty() { + return elapsedTime; + } + + /** + * Returns the long property of the time limit set for the game. + * + * @return the long property of the time limit set for the game + */ + public LongProperty getTimeLimitProperty() { + return timeLimit; + } + + /** + * Returns the boolean property indicating if the Sudoku game has been solved. + * + * @return the boolean property indicating if the Sudoku game has been solved + */ + public BooleanProperty isSolvedProperty() { + return isSolved; + } + + /** + * Returns the boolean property indicating if the time limit for the game has been exceeded. + * + * @return the boolean property indicating if the time limit for the game has been exceeded + */ + public BooleanProperty isLimitExceededProperty() { + return isLimitExceeded; + } + + /** Starts a new game by resetting the game, initializing a new timer and starting it. */ + public void startGame() { + resetGame(); + Long startTime = System.nanoTime(); + timer = + new AnimationTimer() { + @Override + public void handle(long now) { + long elapsedSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startTime); + elapsedTime.set(elapsedSeconds); + if (getPropertyString(SETTINGS, "checkTime").equals("true") + && (elapsedSeconds >= sudokuBoard.difficultyLevel().getMaxSecondsToSolve())) { + stop(); + Platform.runLater(() -> isLimitExceeded.set(true)); + } + } + }; + timer.start(); + increasePropertyByOne(sudokuBoard.difficultyLevel().getTranslationProperty() + "GameStarted"); + } + + /** + * Checks if the input is valid by comparing it with the solution grid. Updates the mistake count + * and game status accordingly. + * + * @param inputValue the input value to be checked + * @param row the row index of the input field + * @param column the column index of the input field + * @return true if the input is valid, false otherwise + */ + public boolean checkInput(byte inputValue, int row, int column) { + currentGrid[row][column] = inputValue; + + if (sudokuBoard.solvedGrid()[row][column] == inputValue) { + if (Arrays.deepEquals(currentGrid, sudokuBoard.solvedGrid())) { + timer.stop(); + isSolved.set(true); + updateStatistics(); + } + return true; + } else { + mistakes.set(mistakes.get() + 1); + if (getPropertyString(SETTINGS, "checkMistakes").equals("true") + && (mistakes.get() >= maxMistakes.get())) { + timer.stop(); + isLimitExceeded.set(true); + } + return false; + } + } + + /** + * Checks if the input field is a default (given) number or not. + * + * @param fieldRow the row index of the input field + * @param fieldColumn the column index of the input field + * @return true if the input field contains a default number, false otherwise + */ + public boolean isDefaultNumber(int fieldRow, int fieldColumn) { + return sudokuBoard.unsolvedGrid()[fieldRow][fieldColumn] != 0; + } + + /** + * Creates a new Sudoku puzzle with the given difficulty level. + * + * @param difficultyLevel the desired difficulty level of the Sudoku puzzle + */ + public void createSudoku(DifficultyLevel difficultyLevel) { + Sudoku sudoku = Creator.createSudoku(difficultyLevel); + + List solutions = Solver.solve(sudoku); + + if (solutions.size() == 1) { + sudokuBoard = new SudokuBoard(sudoku.getAll(), solutions.get(0).getAll(), difficultyLevel); + } else { + log.error("Sudoku generation failed. Solution size: " + solutions.size()); + } + + setDifficultyLevelLimits(); + } + + /** + * Loads a sudoku game from a file and initializes the sudoku board with it. This method throws an + * InvalidFileFormatException or InvalidSudokuException if the file format is incorrect or the + * sudoku in the file is invalid. + * + * @param sudokuFile a File object representing the file from which to load the sudoku game + * @throws InvalidFileFormatException if the file format is incorrect + * @throws InvalidSudokuException if the sudoku in the file is invalid + */ + public void createSudoku(File sudokuFile) + throws InvalidFileFormatException, InvalidSudokuException { + sudokuBoard = sudokuFileLoader.loadSudokuFile(sudokuFile, true); + setGivenNumbers(); + setDifficultyLevelLimits(); + } + + /** Stops the timer.. */ + public void stopGame() { + timer.stop(); + } + + private void setGivenNumbers() { + currentGrid = new byte[sudokuBoard.unsolvedGrid().length][sudokuBoard.unsolvedGrid().length]; + + // Copy the elements from the original array to the new array + IntStream.range(0, sudokuBoard.unsolvedGrid().length) + .forEach(i -> currentGrid[i] = sudokuBoard.unsolvedGrid()[i].clone()); + } + + private void resetGame() { + mistakes.set(0); + elapsedTime.set(0); + isSolved.set(false); + isLimitExceeded.set(false); + setGivenNumbers(); + setDifficultyLevelLimits(); + } + + private void setDifficultyLevelLimits() { + maxMistakes.set(sudokuBoard.difficultyLevel().getMaxErrorsToSolve()); + timeLimit.set(TimeUnit.SECONDS.toSeconds(sudokuBoard.difficultyLevel().getMaxSecondsToSolve())); + } + + private void updateStatistics() { + increasePropertyByOne(sudokuBoard.difficultyLevel().getTranslationProperty() + "GameWon"); + updateBestTime( + sudokuBoard.difficultyLevel().getTranslationProperty() + "GameBestTime", + (int) elapsedTime.get()); + increasePropertyByCount( + sudokuBoard.difficultyLevel().getTranslationProperty() + "GameTimePlayed", + (int) elapsedTime.get()); + increasePropertyByCount( + sudokuBoard.difficultyLevel().getTranslationProperty() + "GameMistakes", mistakes.get()); + } + + private void increasePropertyByOne(String propertyName) { + int gamesStarted = parseInt(getPropertyString(STATISTICS, propertyName)); + gamesStarted += 1; + updatePropertyString(STATISTICS, propertyName, String.valueOf(gamesStarted)); + } + + private void increasePropertyByCount(String propertyName, int count) { + int gamesStarted = parseInt(getPropertyString(STATISTICS, propertyName)); + gamesStarted += count; + updatePropertyString(STATISTICS, propertyName, String.valueOf(gamesStarted)); + } + + private void updateBestTime(String propertyName, int newBestTime) { + int oldBestTime = parseInt(getPropertyString(STATISTICS, propertyName)); + if (oldBestTime == 0 || newBestTime < oldBestTime) { + updatePropertyString(STATISTICS, propertyName, String.valueOf(newBestTime)); + } + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/upload/FileValidator.java b/src/main/java/ch/zhaw/pm2/amongdigits/upload/FileValidator.java new file mode 100644 index 0000000..1dfee87 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/upload/FileValidator.java @@ -0,0 +1,145 @@ +package ch.zhaw.pm2.amongdigits.upload; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; + +import ch.zhaw.pm2.amongdigits.utils.SudokuConstants; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Scanner; + +/** + * The FileValidator class is responsible for validating and processing a Sudoku file upload. It + * provides methods to validate the file format, the grid size, the grid separator and empty cells, + * as well as to create a scanner object to read the file contents. Additionally, it provides + * methods to check if a string is null, empty or blank, to check if the separator is reached, to + * match the grid size, to check if a cell is a non-zero digit or an empty cell, and to check if a + * file is of the correct .txt format. + */ +public class FileValidator { + + private final Integer gridSize; + private final Character gridSeparator; + private final Character emptyGridCell; + + /** + * Constructs a new {@code FileValidator} object with the specified parameters. + * + * @param gridSize the size of the Sudoku grid + * @param gridSeparator the separator used between cells in the grid + * @param emptyGridCell the character representing an empty cell in the grid + */ + public FileValidator( + final Integer gridSize, final Character gridSeparator, final Character emptyGridCell) { + this.gridSize = requireNonNull(gridSize); + this.gridSeparator = requireNonNull(gridSeparator); + this.emptyGridCell = requireNonNull(emptyGridCell); + } + + /** + * Returns the size of the Sudoku grid. + * + * @return the size of the Sudoku grid + */ + Integer getGridSize() { + return gridSize; + } + + /** + * Returns the separator used between cells in the grid. + * + * @return the separator used between cells in the grid + */ + Character getGridSeparator() { + return gridSeparator; + } + + /** + * Returns the character representing an empty cell in the grid. + * + * @return the character representing an empty cell in the grid + */ + Character getEmptyGridCell() { + return emptyGridCell; + } + + /** + * Creates a {@link Scanner} object with the given {@link File} in the correct encoding. + * + * @param file the {@link File} to be read + * @return a {@link Scanner} object ready to read the {@link File} contents + * @throws IOException if an I/O error occurs opening the source + */ + Scanner createScanner(final File file) throws IOException { + return new Scanner(requireNonNull(file), UTF_8); + } + + /** + * Checks if the specified string is null, empty or blank. + * + * @param line The string to check. + * @return true if the string is null, empty or blank, false otherwise. + */ + boolean isLineBlank(final String line) { + return line == null || line.isEmpty() || line.isBlank(); + } + + /** + * Checks if the separator character has been reached in the specified string. + * + * @param line The string to check. + * @return true if the separator character has been reached, false otherwise. + */ + boolean isSeparatorReached(final String line) { + return line != null && line.trim().length() == 1 && line.trim().charAt(0) == gridSeparator; + } + + /** + * Checks if the grid lines in the given list match the specified grid size. + * + * @param gridLines the list of grid lines to check + * @return true if the grid lines match the specified grid size, false otherwise + */ + boolean isMatchingGridSize(final List gridLines) { + if (gridLines == null) { + return false; + } + boolean heightMatching = gridLines.size() == gridSize; + boolean widthMatching = gridLines.stream().allMatch(gridLine -> gridLine.length() == gridSize); + + return heightMatching && widthMatching; + } + + /** + * Checks if the given grid cell is a non-zero digit. + * + * @param gridCell the grid cell to check + * @return true if the grid cell is a non-zero digit, false otherwise + */ + boolean isNonZeroDigit(final char gridCell) { + return Character.isDigit(gridCell) && Character.getNumericValue(gridCell) != 0; + } + + /** + * Checks if the given grid cell is an empty cell in the grid. + * + * @param gridCell the grid cell to check + * @return true if the grid cell is empty, false otherwise + */ + boolean isEmptyGridCell(final char gridCell) { + return emptyGridCell == gridCell; + } + + /** + * Checks if the given file name is a text file with the valid file ending. + * + * @param fileName the name of the file to check + * @return true if the file is a text file with the valid file ending, false otherwise + */ + boolean isTxtFile(final String fileName) { + final int lastDotIndex = fileName.lastIndexOf("."); + return lastDotIndex != -1 + && fileName.substring(lastDotIndex + 1).equals(SudokuConstants.VALID_FILE_ENDING); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/upload/SudokuFileLoader.java b/src/main/java/ch/zhaw/pm2/amongdigits/upload/SudokuFileLoader.java new file mode 100644 index 0000000..e2ee599 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/upload/SudokuFileLoader.java @@ -0,0 +1,252 @@ +package ch.zhaw.pm2.amongdigits.upload; + +import static ch.zhaw.pm2.amongdigits.DifficultyLevel.determineDifficultyLevel; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.FILE_AREA_NAME_SEPARATOR; +import static ch.zhaw.pm2.amongdigits.utils.SudokuConstants.VALID_FILE_ENDING; +import static java.lang.String.format; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; + +import ch.zhaw.pm2.amongdigits.ChallengeType; +import ch.zhaw.pm2.amongdigits.DifficultyLevel; +import ch.zhaw.pm2.amongdigits.SudokuBoard; +import ch.zhaw.pm2.amongdigits.exception.InvalidFileFormatException; +import ch.zhaw.pm2.amongdigits.exception.InvalidSudokuException; +import ch.zhaw.pm2.amongdigits.utils.Solver; +import ch.zhaw.pm2.amongdigits.utils.matrix.Matrix; +import ch.zhaw.pm2.amongdigits.utils.sudoku.SudokuManager; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.ResourceBundle; +import java.util.Scanner; +import javafx.fxml.FXML; + +/** + * This class provides methods to load and upload Sudoku files. It uses a FileValidator to validate + * the format and size of the files and a SudokuManager to manage the Sudoku puzzle. + */ +public class SudokuFileLoader { + + private final FileValidator fileValidator; + private final SudokuManager sudokuManager; + @FXML private final ResourceBundle resourceBundle; + + /** + * Constructor for SudokuFileLoader class. It takes a FileValidator to validate files, a + * SudokuManager to manage the puzzle, and a ResourceBundle to get localized strings. + * + * @param fileValidator The FileValidator to validate the files. + * @param sudokuManager The SudokuManager to manage the puzzle. + * @param resourceBundle The ResourceBundle to get localized strings. + */ + public SudokuFileLoader( + final FileValidator fileValidator, + final SudokuManager sudokuManager, + ResourceBundle resourceBundle) { + this.fileValidator = requireNonNull(fileValidator); + this.sudokuManager = sudokuManager; + this.resourceBundle = resourceBundle; + } + + /** + * This method loads a Sudoku file and sets the unsolved SudokuBoard to the SudokuManager. Then it + * solves the SudokuBoard and sets the solution to the SudokuManager. If the Sudoku has more than + * one solution, it throws an InvalidSudokuException. Finally, it creates a new SudokuBoard with + * the unsolved grid, solved grid and difficulty level, and persists the SudokuBoard to a file + * with the same name as the original file. + * + * @param sudokuFile The file to load the Sudoku puzzle from. + * @throws InvalidFileFormatException If the file is not in the correct format or size. + * @throws InvalidSudokuException If the Sudoku puzzle has no unique solution. + */ + public void uploadSudoku(final File sudokuFile) + throws InvalidFileFormatException, InvalidSudokuException { + final SudokuBoard unsolvedSudokuBoard = loadSudokuFile(sudokuFile, false); + sudokuManager.setAll(unsolvedSudokuBoard.unsolvedGrid()); + final List solutions = Solver.solve(sudokuManager); + if (solutions.size() != 1) { + throw new InvalidSudokuException(resourceBundle.getString("no_unique_solution_exception")); + } + + final SudokuBoard fullSudokuBoard = + new SudokuBoard( + unsolvedSudokuBoard.unsolvedGrid(), + solutions.get(0).getAll(), + DifficultyLevel.determineDifficultyLevel(unsolvedSudokuBoard.unsolvedGrid())); + persistSudokuFile(fullSudokuBoard, resolveFileName(sudokuFile.getName())); + } + + /** + * This method loads a Sudoku file and returns an unsolved SudokuBoard with the unsolved grid. If + * containsSolution is true, it also reads the solved grid from the file. It validates the file + * format and size using the FileValidator. It throws an InvalidFileFormatException if the file is + * not in the correct format or size. It throws an InvalidSudokuException if containsSolution is + * true and the unsolved grid is not a subset of the solved grid. + * + * @param sudokuFile The file to load the Sudoku puzzle from. + * @param containsSolution A boolean indicating whether the file contains the solution grid. + * @return A SudokuBoard with the unsolved grid. + * @throws InvalidFileFormatException If the file is not in the correct format or size. + * @throws InvalidSudokuException If the unsolved grid is not a subset of the solved grid. + */ + public SudokuBoard loadSudokuFile(final File sudokuFile, boolean containsSolution) + throws InvalidFileFormatException, InvalidSudokuException { + if (!fileValidator.isTxtFile(sudokuFile.getName())) { + throw new InvalidFileFormatException(resourceBundle.getString("no_txt_file_exception")); + } + final byte[][] unsolvedGrid = + new byte[fileValidator.getGridSize()][fileValidator.getGridSize()]; + final byte[][] solvedGrid = new byte[fileValidator.getGridSize()][fileValidator.getGridSize()]; + final List unsolvedGridLines = new ArrayList<>(fileValidator.getGridSize()); + final List solvedGridLines = new ArrayList<>(fileValidator.getGridSize()); + + try (final Scanner scanner = fileValidator.createScanner(sudokuFile)) { + readFile(unsolvedGridLines, solvedGridLines, scanner); + } catch (final IOException e) { + throw new InvalidFileFormatException( + format(resourceBundle.getString("not_parseable_exception"), sudokuFile.getName()), e); + } + + if (!fileValidator.isMatchingGridSize(unsolvedGridLines) + || (containsSolution && !fileValidator.isMatchingGridSize(solvedGridLines))) { + throw new InvalidFileFormatException( + format( + resourceBundle.getString("wrong_grid_size_exception"), fileValidator.getGridSize())); + } + + fillGrid(unsolvedGridLines, unsolvedGrid); + if (containsSolution) { + fillGrid(solvedGridLines, solvedGrid); + } + + if (containsSolution && !areSubsets(unsolvedGrid, solvedGrid)) { + throw new InvalidSudokuException(resourceBundle.getString("sudoku_not_compatible_exception")); + } + return new SudokuBoard( + unsolvedGrid, solvedGrid, DifficultyLevel.determineDifficultyLevel(unsolvedGrid)); + } + + private void readFile( + final List unsolvedGridLines, + final List solvedGridLines, + final Scanner scanner) { + boolean isParsingUnsolvedGrid = true; + while (scanner.hasNextLine()) { + final String currentLine = scanner.nextLine(); + if (!fileValidator.isLineBlank(currentLine)) { + final String trimmedLine = currentLine.trim(); + if (isParsingUnsolvedGrid && fileValidator.isSeparatorReached(trimmedLine)) { + isParsingUnsolvedGrid = false; + } else if (isParsingUnsolvedGrid) { + unsolvedGridLines.add(trimmedLine); + } else { + solvedGridLines.add(trimmedLine); + } + } + } + } + + private void fillGrid(final List gridLines, final byte[][] grid) + throws InvalidFileFormatException { + for (int row = 0; row < fileValidator.getGridSize(); row++) { + final String currentRow = gridLines.get(row); + for (int col = 0; col < fileValidator.getGridSize(); col++) { + fillGridCell(grid, row, col, currentRow); + } + } + } + + private void fillGridCell( + final byte[][] grid, final int row, final int col, final String currentRow) + throws InvalidFileFormatException { + final char currentGridCell = currentRow.charAt(col); + if (fileValidator.isNonZeroDigit(currentGridCell)) { + grid[row][col] = (byte) Character.getNumericValue(currentGridCell); + } else if (!fileValidator.isEmptyGridCell(currentGridCell)) { + throw new InvalidFileFormatException( + format( + resourceBundle.getString("invalid_grid_cell_exception"), + fileValidator.getGridSeparator(), + fileValidator.getEmptyGridCell())); + } + } + + private boolean areSubsets(final byte[][] unsolvedGrid, final byte[][] solvedGrid) { + for (int row = 0; row < fileValidator.getGridSize(); row++) { + for (int col = 0; col < fileValidator.getGridSize(); col++) { + int unsolvedGridDigit = unsolvedGrid[row][col]; + int solvedGridDigit = solvedGrid[row][col]; + if (unsolvedGridDigit != 0 && unsolvedGridDigit != solvedGridDigit) { + return false; + } + } + } + return true; + } + + private String resolveFileName(final String fileName) { + return fileName.substring(0, fileName.lastIndexOf(".")); + } + + private void persistSudokuFile(final SudokuBoard sudokuBoard, final String fileName) + throws InvalidSudokuException, InvalidFileFormatException { + final URL uploadDirectory = + requireNonNull( + getClass().getClassLoader().getResource(ChallengeType.USER_GENERATED.getDirectory())); + final String uploadFileName = + determineDifficultyLevel(sudokuBoard.unsolvedGrid()).name() + + FILE_AREA_NAME_SEPARATOR + + fileName + + FILE_AREA_NAME_SEPARATOR + + sudokuBoard.hashCode() + + "." + + VALID_FILE_ENDING; + final File uploadFile = new File(uploadDirectory.getFile(), uploadFileName); + + createFile(uploadFile); + writeFile(sudokuBoard, uploadFile); + } + + private void createFile(File uploadFile) + throws InvalidSudokuException, InvalidFileFormatException { + try { + if (!uploadFile.createNewFile()) { + throw new InvalidSudokuException( + format(resourceBundle.getString("sudoku_exists_exception"), uploadFile.getName())); + } + } catch (final IOException e) { + throw new InvalidFileFormatException( + format(resourceBundle.getString("sudoku_upload_io_exception"), e.getMessage())); + } + } + + private void writeFile(SudokuBoard sudokuBoard, File uploadFile) + throws InvalidFileFormatException { + try (final FileWriter fileWriter = new FileWriter(uploadFile, UTF_8, true); + final BufferedWriter bufferedWriter = new BufferedWriter(fileWriter)) { + writeRow(sudokuBoard.unsolvedGrid(), bufferedWriter); + bufferedWriter.write(fileValidator.getGridSeparator()); + bufferedWriter.newLine(); + writeRow(sudokuBoard.solvedGrid(), bufferedWriter); + } catch (IOException e) { + throw new InvalidFileFormatException( + format(resourceBundle.getString("sudoku_upload_io_exception"), e.getMessage())); + } + } + + private void writeRow(byte[][] grid, BufferedWriter bufferedWriter) throws IOException { + for (final byte[] currentRow : grid) { + final StringBuilder rowBuilder = new StringBuilder(); + for (byte cell : currentRow) { + rowBuilder.append(cell); + } + bufferedWriter.write(rowBuilder.toString().replace('0', fileValidator.getEmptyGridCell())); + bufferedWriter.newLine(); + } + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/Creator.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/Creator.java new file mode 100644 index 0000000..22c260e --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/Creator.java @@ -0,0 +1,264 @@ +package ch.zhaw.pm2.amongdigits.utils; + +import static ch.zhaw.pm2.amongdigits.utils.Creator.BacktrackingResult.CONTEST; +import static ch.zhaw.pm2.amongdigits.utils.Creator.BacktrackingResult.CONTINUE; +import static ch.zhaw.pm2.amongdigits.utils.Creator.BacktrackingResult.FOUND; +import static ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager.FreeCellResult.CONTRADICTION; +import static ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager.FreeCellResult.NONE_FREE; + +import ch.zhaw.pm2.amongdigits.DifficultyLevel; +import ch.zhaw.pm2.amongdigits.utils.matrix.CachedMatrixManager; +import ch.zhaw.pm2.amongdigits.utils.matrix.Matrix; +import ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager; +import ch.zhaw.pm2.amongdigits.utils.schema.Schema; +import ch.zhaw.pm2.amongdigits.utils.schema.SchemaTypes; +import ch.zhaw.pm2.amongdigits.utils.sudoku.Sudoku; +import ch.zhaw.pm2.amongdigits.utils.sudoku.SudokuManager; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.function.Function; + +/** + * This class provides functionality to create a Sudoku puzzle with a given difficulty level. It + * uses a cached matrix manager to create a full Sudoku matrix and then removes a certain number of + * elements to generate the puzzle. + */ +public final class Creator { + + private static final int SUDOKU_EMPTY_FIELDS_RANDOM = 10; + private static final Random RANDOM = new Random(); + + private final Function resultConsumer; + private final MatrixManager matrixManager; + private final Schema schema; + private Matrix winner; + + private Creator(final Schema schema) { + this.schema = schema; + matrixManager = new CachedMatrixManager(schema); + + resultConsumer = + matrix -> { + winner = matrix; + return true; + }; + } + + /** + * Creates a Sudoku puzzle with the given difficulty level. + * + * @param difficultyLevel the difficulty level of the puzzle to create + * @return a Sudoku puzzle with the given difficulty level + */ + public static Sudoku createSudoku(final DifficultyLevel difficultyLevel) { + final Matrix fullMatrix = createFull(); + final Schema schema = fullMatrix.getSchema(); + final int width = schema.getWidth(); + final byte unset = schema.getUnsetValue(); + + SudokuManager sudokuManager = new SudokuManager(schema); + sudokuManager.setAll(fullMatrix.getAll()); + + int numbersToClear = difficultyLevel.getMaxNumbersToClear(); + int randomClearCount = 0; + + while (numbersToClear > 0 && randomClearCount < SUDOKU_EMPTY_FIELDS_RANDOM) { + int i = RANDOM.nextInt(width); + int j = RANDOM.nextInt(width); + if (sudokuManager.get(j, i) != schema.getUnsetValue()) { + if (isClearable(sudokuManager, j, i)) { + sudokuManager.set(j, i, schema.getUnsetValue()); + numbersToClear--; + } else { + randomClearCount++; + } + } + } + + clearNumbers(sudokuManager, width, unset, numbersToClear); + setWritableCells(sudokuManager, width, unset); + + return sudokuManager; + } + + /** + * Creates a full Sudoku matrix. + * + * @return a full Sudoku matrix + */ + static Matrix createFull() { + Schema schema = SchemaTypes.SCHEMA_9X9; + Creator creator = new Creator(schema); + + BacktrackingResult backtrackingResult; + do { + creator.matrixManager.clear(); + for (int i = 0; i < creator.matrixManager.getSchema().getBlockCount(); i++) { + creator.fillBlock(i * schema.getBlockWidth(), i * schema.getBlockWidth()); + } + + backtrackingResult = + creator.backtrack( + schema.getTotalFields() - creator.matrixManager.getSetCount(), new int[2]); + } while (backtrackingResult != FOUND); + + return creator.winner; + } + + /** + * Creates an array of numbers to distribute across the Sudoku matrix. + * + * @param schema the schema of the Sudoku matrix + * @param multiplicity the multiplicity of each number to distribute + * @return an array of numbers to distribute across the Sudoku matrix + */ + static byte[] createNumbersToDistribute(final Schema schema, final int multiplicity) { + int totalNumbers = schema.getMaximumValue() - schema.getMinimumValue() + 1; + List numbersToDistribute = new ArrayList<>(totalNumbers * multiplicity); + for (int number = schema.getMinimumValue(); number <= schema.getMaximumValue(); number++) { + for (int j = 0; j < multiplicity; j++) { + numbersToDistribute.add(number); + } + } + + Collections.shuffle(numbersToDistribute); + byte[] numbersToDistributeArray = new byte[numbersToDistribute.size()]; + int k = 0; + for (Integer number : numbersToDistribute) { + numbersToDistributeArray[k++] = number.byteValue(); + } + + return numbersToDistributeArray; + } + + /** + * Calculates the offset of a set bit in a mask at the given bit index. + * + * @param mask the mask to check + * @param bitIndex the index of the bit to check + * @return the offset of the set bit at the given bit index, or -1 if no set bit was found + */ + static int getSetBitOffset(final int mask, final int bitIndex) { + int count = 0; + int workingMask = mask; + int low = Integer.numberOfTrailingZeros(workingMask); + workingMask >>>= low; + assert (workingMask & 1) == 1 || workingMask == 0; + for (int i = low; workingMask != 0; i++) { + if ((workingMask & 1) != 0) { + if (count == bitIndex) { + return i; + } + count++; + } + workingMask >>>= 1; + } + + return -1; + } + + private static boolean isClearable( + final SudokuManager sudokuManager, final int row, final int column) { + Schema schema = sudokuManager.getSchema(); + assert sudokuManager.get(row, column) != schema.getUnsetValue(); + + int freeMask = sudokuManager.getFreeMask(row, column); + int freeValues = Integer.bitCount(freeMask); + if (freeValues == 0) { + return true; + } + + int oldSudoku = sudokuManager.get(row, column); + sudokuManager.set(row, column, schema.getUnsetValue()); + + List results = Solver.solve(sudokuManager, 2); + boolean result = results.size() == 1; + + sudokuManager.set(row, column, (byte) oldSudoku); + return result; + } + + private static void clearNumbers( + SudokuManager sudokuManager, int width, byte unset, int numbersToClear) { + for (int i = 0; i < width; i++) { + for (int j = 0; j < width; j++) { + if (numbersToClear > 0 + && unset != sudokuManager.get(j, i) + && isClearable(sudokuManager, j, i)) { + sudokuManager.set(j, i, unset); + numbersToClear--; + } + } + } + } + + private static void setWritableCells(SudokuManager sudokuManager, int width, byte unset) { + for (int i = 0; i < width; i++) { + for (int j = 0; j < width; j++) { + sudokuManager.setWritable(j, i, sudokuManager.get(j, i) == unset); + } + } + } + + private void fillBlock(final int row, final int column) { + final int blockSize = schema.getBlockWidth(); + + assert schema.areCoordsValid(row, column); + assert row % blockSize == 0; + assert column % blockSize == 0; + + byte[] numbers = createNumbersToDistribute(schema, 1); + int k = 0; + for (int colOfs = 0; colOfs < blockSize; colOfs++) { + for (int rowOfs = 0; rowOfs < blockSize; rowOfs++) { + matrixManager.set(row + rowOfs, column + colOfs, numbers[k++]); + } + } + } + + private BacktrackingResult backtrack(final int numbersToDistribute, final int[] minimumCell) { + if (numbersToDistribute == 0) { + assert matrixManager.isValid(); + if (Boolean.TRUE.equals(resultConsumer.apply(matrixManager))) { + return FOUND; + } else { + return CONTINUE; + } + } + + MatrixManager.FreeCellResult result = matrixManager.findLeastFreeCell(minimumCell); + if (result == CONTRADICTION || result == NONE_FREE) { + return CONTEST; + } + + int minimumRow = minimumCell[0]; + int minimumColumn = minimumCell[1]; + int minimumFree = matrixManager.getFreeMask(minimumRow, minimumColumn); + int minimumBits = Integer.bitCount(minimumFree); + + for (int bit = 0; bit < minimumBits; bit++) { + int number = getSetBitOffset(minimumFree, bit); + assert number >= schema.getMinimumValue() && number <= schema.getMaximumValue(); + assert (matrixManager.getFreeMask(minimumRow, minimumColumn) & (1 << number)) == 1 << number; + + matrixManager.set(minimumRow, minimumColumn, (byte) (number)); + assert (matrixManager.getFreeMask(minimumRow, minimumColumn) & (1 << number)) == 0; + BacktrackingResult subResult = backtrack(numbersToDistribute - 1, minimumCell); + if (subResult == FOUND) { + return subResult; + } + } + matrixManager.set(minimumRow, minimumColumn, schema.getUnsetValue()); + + return CONTINUE; + } + + /** The result of a backtracking operation. */ + enum BacktrackingResult { + FOUND, + CONTINUE, + CONTEST + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/PropertiesHandler.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/PropertiesHandler.java new file mode 100644 index 0000000..3fed21e --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/PropertiesHandler.java @@ -0,0 +1,107 @@ +package ch.zhaw.pm2.amongdigits.utils; + +import static java.lang.String.format; + +import ch.zhaw.pm2.amongdigits.PropertyType; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Properties; +import lombok.extern.slf4j.Slf4j; + +/** Utility class for handling properties files. */ +@Slf4j +public final class PropertiesHandler { + + private static final String FILE_NOT_FOUND = "File not found: %S"; + private static final String ERROR_READING_FILE = "Error reading File: %S"; + + private PropertiesHandler() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Retrieves the value of a specific property from the given property type. + * + * @param propertyType The type of the property file. + * @param key The key of the property. + * @return The value of the specified property, or null if not found. + */ + public static String getPropertyString(PropertyType propertyType, String key) { + return loadProperties(propertyType).getProperty(key); + } + + /** + * Updates the value of a specific property in the given property type. + * + * @param propertyType The type of the property file. + * @param key The key of the property. + * @param newValue The new value for the property. + */ + public static void updatePropertyString(PropertyType propertyType, String key, String newValue) { + testPropertiesSetup(propertyType); + Properties updateProperty = loadProperties(propertyType); + updateProperty.setProperty(key, newValue); + storeProperties(propertyType, updateProperty); + } + + /** + * Stores the properties to the specified property file. + * + * @param propertyType The type of the property file. + * @param property The properties to be stored. + */ + public static void storeProperties(PropertyType propertyType, Properties property) { + testPropertiesSetup(propertyType); + try (FileWriter fileWriter = new FileWriter(propertyType.getFileName())) { + property.store(fileWriter, null); + } catch (FileNotFoundException e) { + log.error(format(FILE_NOT_FOUND, e.getMessage())); + } catch (IOException e) { + log.error(format(ERROR_READING_FILE, e.getMessage())); + } + } + + /** + * Loads the properties from the specified property file. + * + * @param propertyType The type of the property file. + * @return The loaded properties. + */ + public static Properties loadProperties(PropertyType propertyType) { + testPropertiesSetup(propertyType); + Properties loadedProperty = new Properties(); + try (FileInputStream fileInputStream = new FileInputStream(propertyType.getFileName())) { + loadedProperty.load(fileInputStream); + } catch (FileNotFoundException e) { + log.error(format(FILE_NOT_FOUND, e.getMessage())); + } catch (IOException e) { + log.error(format(ERROR_READING_FILE, e.getMessage())); + } + return loadedProperty; + } + + private static void testPropertiesSetup(PropertyType propertyType) { + File settingsFile = new File(propertyType.getFileName()); + if (!settingsFile.exists()) { + setupProperties(propertyType, settingsFile); + } + } + + private static void setupProperties(PropertyType propertyType, File settingsFile) { + Properties property = new Properties(); + File defaultSettingsFile = + new File((settingsFile.getParentFile() + "/default" + settingsFile.getName())); + try (FileInputStream fileInputStream = new FileInputStream(defaultSettingsFile.toString()); + FileWriter fileWriter = new FileWriter(propertyType.getFileName())) { + property.load(fileInputStream); + property.store(fileWriter, null); + } catch (FileNotFoundException e) { + log.error(format(FILE_NOT_FOUND, e.getMessage())); + } catch (IOException e) { + log.error(format(ERROR_READING_FILE, e.getMessage())); + } + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/Solver.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/Solver.java new file mode 100644 index 0000000..aeba7eb --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/Solver.java @@ -0,0 +1,99 @@ +package ch.zhaw.pm2.amongdigits.utils; + +import static ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager.FreeCellResult.FOUND; + +import ch.zhaw.pm2.amongdigits.utils.matrix.CachedMatrixManager; +import ch.zhaw.pm2.amongdigits.utils.matrix.Matrix; +import ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Utility class for solving matrix problems. */ +public final class Solver { + + /** The maximum number of solutions to find. */ + public static final int MAX_SOLUTIONS = 1; + + private final CachedMatrixManager cachedMatrixManager; + private final List possibleSolutions; + + private Solver(final Matrix matrix) { + Objects.requireNonNull(matrix, "Matrix must not be null"); + cachedMatrixManager = new CachedMatrixManager(matrix.getSchema()); + cachedMatrixManager.setAll(matrix.getAll()); + possibleSolutions = new ArrayList<>(); + } + + /** + * Solves the given matrix and returns a list of possible solutions. + * + * @param matrix The matrix to solve. + * @return A list of possible solutions. + */ + public static List solve(final Matrix matrix) { + return solve(matrix, MAX_SOLUTIONS); + } + + /** + * Solves the given matrix and returns a list of possible solutions up to the specified maximum + * number. + * + * @param matrix The matrix to solve. + * @param maxSolutions The maximum number of solutions to find. + * @return A list of possible solutions. + */ + public static List solve(final Matrix matrix, final int maxSolutions) { + Solver solver = new Solver(matrix); + solver.possibleSolutions.clear(); + int freeCells = + solver.cachedMatrixManager.getSchema().getTotalFields() + - solver.cachedMatrixManager.getSetCount(); + + backtrack(freeCells, new int[2], maxSolutions, solver); + + return Collections.unmodifiableList(solver.possibleSolutions); + } + + private static int backtrack( + final int freeCells, final int[] minimumCell, final int maxSolutions, final Solver solver) { + assert freeCells >= 0; + if (solver.possibleSolutions.size() >= maxSolutions) { + return 0; + } + + if (freeCells == 0) { + Matrix matrix = new MatrixManager(solver.cachedMatrixManager.getSchema()); + matrix.setAll(solver.cachedMatrixManager.getAll()); + solver.possibleSolutions.add(matrix); + + return 1; + } + + MatrixManager.FreeCellResult freeCellResult = + solver.cachedMatrixManager.findLeastFreeCell(minimumCell); + if (freeCellResult != FOUND) { + return 0; + } + + int result = 0; + int minimumRow = minimumCell[0]; + int minimumColumn = minimumCell[1]; + int minimumFree = solver.cachedMatrixManager.getFreeMask(minimumRow, minimumColumn); + int minimumBits = Integer.bitCount(minimumFree); + + for (int bit = 0; bit < minimumBits; bit++) { + int index = Creator.getSetBitOffset(minimumFree, bit); + assert index > 0; + + solver.cachedMatrixManager.set(minimumRow, minimumColumn, (byte) index); + int resultCount = backtrack(freeCells - 1, minimumCell, maxSolutions, solver); + result += resultCount; + } + solver.cachedMatrixManager.set( + minimumRow, minimumColumn, solver.cachedMatrixManager.getSchema().getUnsetValue()); + + return result; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/SudokuConstants.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/SudokuConstants.java new file mode 100644 index 0000000..a74773c --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/SudokuConstants.java @@ -0,0 +1,42 @@ +package ch.zhaw.pm2.amongdigits.utils; + +/** Constants related to Sudoku. */ +public class SudokuConstants { + + /** The size of the Sudoku grid. */ + public static final int SUDOKU_GRID_SIZE = 9; + + /** The separator character used in the grid representation. */ + public static final char GRID_SEPARATOR = '*'; + + /** The character used to represent an empty cell in the grid. */ + public static final char EMPTY_GRID_CELL = '-'; + + /** The separator character used in file area names. */ + public static final char FILE_AREA_NAME_SEPARATOR = '_'; + + /** The valid file ending for Sudoku files. */ + public static final String VALID_FILE_ENDING = "txt"; + + /** The symbol for infinity. */ + public static final String INFINITY_SYMBOL = "\u221E"; + + /** The format for displaying the clock time in minutes and seconds. */ + public static final String CLOCK_FORMAT = "%d:%02d"; + + /** The format for displaying an empty clock time. */ + public static final String EMPTY_CLOCK_FORMAT = "--:--"; + + /** The symbol for indicating an enabled state. */ + public static final String ENABLED_SYMBOL = "\u2713"; + + /** The symbol for indicating a disabled state. */ + public static final String DISABLED_SYMBOL = "X"; + + /** The threshold value for converting seconds to minutes. */ + public static final int SECOND_MINUTE_THRESHOLD = 60; + + private SudokuConstants() { + throw new UnsupportedOperationException("Utility class"); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/alert/AlertBuilder.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/alert/AlertBuilder.java new file mode 100644 index 0000000..282d880 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/alert/AlertBuilder.java @@ -0,0 +1,51 @@ +package ch.zhaw.pm2.amongdigits.utils.alert; + +import static ch.zhaw.pm2.amongdigits.PropertyType.SETTINGS; +import static ch.zhaw.pm2.amongdigits.utils.PropertiesHandler.getPropertyString; + +import java.util.Objects; +import java.util.Optional; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.image.ImageView; + +/** This class provides a utility to build and show JavaFX Alert dialogs with custom options. */ +public class AlertBuilder { + + private static final Integer DEFAULT_SIZE = 200; + + private AlertBuilder() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Private constructor to prevent instantiation of this utility class. + * + * @throws UnsupportedOperationException if called + */ + public static Optional showAlert(AlertOptions alertRecord) { + final Alert alert = new Alert(alertRecord.type()); + alert + .getDialogPane() + .getStylesheets() + .add( + Objects.requireNonNull( + Objects.requireNonNull( + AlertBuilder.class.getResource( + getPropertyString(SETTINGS, "cssFileString"))) + .toString())); + alert.setTitle(alertRecord.title()); + alert.setHeaderText(alertRecord.header()); + alert.contentTextProperty().set(alertRecord.content()); + if (alertRecord.buttons() != null && !alertRecord.buttons().isEmpty()) { + alert.getButtonTypes().setAll(alertRecord.buttons()); + } + if (alertRecord.image() != null) { + final ImageView imageView = new ImageView(alertRecord.image()); + imageView.setFitWidth(DEFAULT_SIZE); + imageView.setFitHeight(DEFAULT_SIZE); + alert.setGraphic(imageView); + } + return alert.showAndWait(); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/alert/AlertOptions.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/alert/AlertOptions.java new file mode 100644 index 0000000..9152bd2 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/alert/AlertOptions.java @@ -0,0 +1,18 @@ +package ch.zhaw.pm2.amongdigits.utils.alert; + +import java.util.Set; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.image.Image; + +/** + * A record that represents the options to be used when displaying an alert. The alert can have a + * type, a title, a header, a content, an image, and a set of buttons. + */ +public record AlertOptions( + Alert.AlertType type, + String title, + String header, + String content, + Image image, + Set buttons) {} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/CachedMatrixManager.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/CachedMatrixManager.java new file mode 100644 index 0000000..3948252 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/CachedMatrixManager.java @@ -0,0 +1,179 @@ +package ch.zhaw.pm2.amongdigits.utils.matrix; + +import ch.zhaw.pm2.amongdigits.utils.schema.Schema; + +/** + * The CachedMatrixManager class extends the MatrixManager class and implements an optimized version + * of a Sudoku puzzle matrix that uses caching to improve performance. The cached matrix stores the + * free cell options in rowFree, columnFree, and blockFree arrays and the number of set cells in + * setCount. + */ +public class CachedMatrixManager extends MatrixManager { + + private final int[] rowFree; + private final int[] columnFree; + private final int[][] blockFree; + private int setCount; + + /** + * Constructs a new CachedMatrixManager object with the specified schema. + * + * @param schema the schema to use for the Sudoku puzzle + */ + public CachedMatrixManager(final Schema schema) { + super(schema); + final int blockCount = schema.getBlockCount(); + final int width = schema.getWidth(); + + blockFree = new int[blockCount][blockCount]; + rowFree = new int[width]; + columnFree = new int[width]; + + for (int i = 0; i < width; i++) { + rowFree[i] = schema.getBitMask(); + columnFree[i] = schema.getBitMask(); + } + + for (int i = 0; i < blockCount; i++) { + for (int j = 0; j < blockCount; j++) { + blockFree[i][j] = schema.getBitMask(); + } + } + } + + /** + * Clones the specified CachedMatrixManager object and returns the new clone. + * + * @param cachedMatrixManager the CachedMatrixManager object to clone + * @return the new clone of the CachedMatrixManager object + */ + static CachedMatrixManager clone(final CachedMatrixManager cachedMatrixManager) { + CachedMatrixManager clone = new CachedMatrixManager(cachedMatrixManager.getSchema()); + clone.setAll(cachedMatrixManager.getAll()); + + return clone; + } + + /** + * Returns the bit mask of the free values for the given block. + * + * @param row The row of the block + * @param column The column of the block + * @return The bit mask of the free values for the given block + */ + @Override + public int getBlockFreeMask(final int row, final int column) { + final int blockWidth = getSchema().getBlockWidth(); + + return blockFree[row / blockWidth][column / blockWidth]; + } + + /** + * Returns the bit mask of the free values for the given column. + * + * @param column The column to check + * @return The bit mask of the free values for the given column + */ + @Override + public int getColumnFreeMask(final int column) { + return columnFree[column]; + } + + /** + * Returns the bit mask of the free values for the given row. + * + * @param row The row to check + * @return The bit mask of the free values for the given row + */ + @Override + public int getRowFreeMask(final int row) { + return rowFree[row]; + } + + /** + * Returns the bit mask of the free values for the given cell. + * + * @param row The row of the cell + * @param column The column of the cell + * @return The bit mask of the free values for the given cell + */ + @Override + public int getFreeMask(final int row, final int column) { + final int blockWidth = getSchema().getBlockWidth(); + + return rowFree[row] & columnFree[column] & blockFree[row / blockWidth][column / blockWidth]; + } + + /** + * Sets a value in the Sudoku puzzle at the specified row and column. + * + * @param row the row index where the value will be set + * @param column the column index where the value will be set + * @param value the value to be set + * @throws IllegalArgumentException if the value is not valid for the puzzle's schema or if the + * value is not allowed at the specified position + * @throws IllegalStateException if any of the row, column, or block free masks are invalid after + * the value is set + */ + @Override + public void set(final int row, final int column, final byte value) { + Schema schema = getSchema(); + + if (!schema.isValueValid(value)) { + throw new IllegalArgumentException("The value " + value + " is not valid."); + } + byte oldValue = super.get(row, column); + assert schema.isValueValid(oldValue); + + final byte unset = schema.getUnsetValue(); + final int blockWidth = schema.getBlockWidth(); + + if (oldValue != unset) { + int bitMask = 1 << oldValue; + rowFree[row] |= bitMask; + columnFree[column] |= bitMask; + blockFree[row / blockWidth][column / blockWidth] = + blockFree[row / blockWidth][column / blockWidth] | bitMask; + setCount--; + assert setCount >= 0; + } + + if (value != unset) { + if ((getFreeMask(row, column) & (1 << value)) == 0) { + throw new IllegalArgumentException( + "Value " + value + " is not allowed at position (" + row + ", " + column + ")"); + } + int bitMask = ~(1 << value); + rowFree[row] &= bitMask; + columnFree[column] &= bitMask; + blockFree[row / blockWidth][column / blockWidth] &= bitMask; + setCount++; + assert setCount <= getSchema().getTotalFields(); + } + + if (!getSchema().isBitMaskValid(rowFree[row])) { + throw new IllegalStateException("Row free mask is invalid: " + rowFree[row]); + } + + if (!getSchema().isBitMaskValid(columnFree[column])) { + throw new IllegalStateException("Column free mask is invalid: " + columnFree[column]); + } + + if (!getSchema().isBitMaskValid(blockFree[row / blockWidth][column / blockWidth])) { + throw new IllegalStateException( + "Block free mask is invalid: " + blockFree[row / blockWidth][column / blockWidth]); + } + + super.set(row, column, value); + } + + /** + * Returns the number of fields that have been set in the Sudoku puzzle. + * + * @return the number of set fields + */ + @Override + public int getSetCount() { + return setCount; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/Matrix.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/Matrix.java new file mode 100644 index 0000000..a4869a5 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/Matrix.java @@ -0,0 +1,71 @@ +package ch.zhaw.pm2.amongdigits.utils.matrix; + +import ch.zhaw.pm2.amongdigits.utils.schema.Schema; + +/** The Matrix interface defines a contract for working with matrices in a Sudoku puzzle. */ +public interface Matrix { + + /** + * Returns the schema of the matrix. + * + * @return the schema of the matrix + */ + Schema getSchema(); + /** Clears the matrix, setting all values to the schema's unset value. */ + void clear(); + /** + * Returns the value at the specified row and column in the matrix. + * + * @param row the row index + * @param column the column index + * @return the value at the specified position + */ + byte get(int row, int column); + /** + * Returns a two-dimensional array containing all values in the matrix. + * + * @return a two-dimensional array containing all values in the matrix + */ + byte[][] getAll(); + /** + * Sets all values in the matrix to the values in the specified two-dimensional array. + * + * @param values the array of values to set + * @throws IllegalArgumentException if the dimensions of the specified array do not match the + * dimensions of the matrix + */ + void setAll(byte[][] values) throws IllegalArgumentException; + /** + * Returns the number of fields that have been set in the matrix. + * + * @return the number of set fields + */ + int getSetCount(); + /** + * Checks whether the matrix is valid according to the schema. + * + * @return true if the matrix is valid, false otherwise + */ + boolean isValid(); + /** + * Checks whether it is possible to set the specified value at the specified row and column in the + * matrix. + * + * @param row the row index + * @param column the column index + * @param value the value to set + * @return true if the value can be set at the specified position, false otherwise + */ + boolean isSetPossible(int row, int column, byte value); + /** + * Sets a value in the matrix at the specified row and column. + * + * @param row the row index where the value will be set + * @param column the column index where the value will be set + * @param value the value to be set + * @throws IllegalArgumentException if the value is not valid for the matrix's schema or if the + * value is not allowed at the specified position + * @throws IllegalStateException if the matrix is no longer valid after the value is set + */ + void set(int row, int column, byte value) throws IllegalArgumentException, IllegalStateException; +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/MatrixManager.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/MatrixManager.java new file mode 100644 index 0000000..3a89201 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/matrix/MatrixManager.java @@ -0,0 +1,427 @@ +package ch.zhaw.pm2.amongdigits.utils.matrix; + +import static ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager.FreeCellResult.CONTRADICTION; +import static ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager.FreeCellResult.FOUND; +import static ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager.FreeCellResult.NONE_FREE; + +import ch.zhaw.pm2.amongdigits.utils.schema.Schema; +import java.util.Arrays; + +/** + * MatrixManager represents an implementation of the Matrix interface. It stores a matrix of bytes + * and a schema defining the valid values and dimensions of the matrix. + */ +public class MatrixManager implements Matrix { + + private final Schema schema; + private final byte[][] matrix; + + /** + * Constructs a new MatrixManager instance with the specified schema. + * + * @param schema the schema used by the matrix. + */ + public MatrixManager(final Schema schema) { + this.schema = schema; + matrix = new byte[schema.getWidth()][schema.getWidth()]; + } + + /** + * Clones a MatrixManager instance by creating a new MatrixManager instance with the same schema + * and the same matrix values as the original. + * + * @param matrixManager the MatrixManager to be cloned. + * @return the cloned MatrixManager instance. + */ + static MatrixManager clone(final MatrixManager matrixManager) { + MatrixManager clone = new MatrixManager(matrixManager.getSchema()); + clone.setAll(matrixManager.getAll()); + + return clone; + } + + /** + * Finds the duplicate bits in an array of bytes. + * + * @param schema the schema used by the array. + * @param array the array of bytes to be searched for duplicate bits. + * @return an integer representing the duplicate bits found in the array. + */ + static int findDuplicateBits(final Schema schema, final byte[] array) { + int bitMask = 0; + int duplicateBits = 0; + byte unset = schema.getUnsetValue(); + for (final byte cellValue : array) { + if (cellValue != unset) { + final int shifted = 1 << cellValue; + duplicateBits |= bitMask & shifted; + bitMask |= shifted; + } + } + + return duplicateBits & (~1); + } + + /** + * Returns the number mask of an array of bytes. + * + * @param schema the schema used by the array. + * @param array the array of bytes to be evaluated. + * @return an integer representing the number mask of the array. + */ + static int getNumberMask(final Schema schema, final byte[] array) { + int bitMask = 0; + final byte unset = schema.getUnsetValue(); + for (byte b : array) { + if (b != unset) { + bitMask |= 1 << b; + } + } + + return bitMask & (~1); + } + + /** {@inheritDoc} */ + @Override + public Schema getSchema() { + return schema; + } + + /** {@inheritDoc} */ + @Override + public void clear() { + byte unsetValue = schema.getUnsetValue(); + for (int i = 0; i < schema.getWidth(); i++) { + for (int j = 0; j < schema.getWidth(); j++) { + set(j, i, unsetValue); + } + } + } + + /** {@inheritDoc} */ + @Override + public byte get(final int row, final int column) { + if (!getSchema().areCoordsValid(row, column)) { + throw new IllegalArgumentException("Coordinates are not valid"); + } + return matrix[row][column]; + } + + /** {@inheritDoc} */ + @Override + public byte[][] getAll() { + return matrix.clone(); + } + + /** {@inheritDoc} */ + @Override + public void setAll(final byte[][] values) { + for (int i = 0; i < schema.getWidth(); i++) { + for (int j = 0; j < schema.getWidth(); j++) { + set(j, i, values[j][i]); + } + } + } + + /** {@inheritDoc} */ + @Override + public int getSetCount() { + int count = 0; + for (int i = 0; i < schema.getWidth(); i++) { + for (int j = 0; j < schema.getWidth(); j++) { + assert getSchema().isValueValid(matrix[i][j]); + if (matrix[i][j] != schema.getUnsetValue()) { + count++; + } + } + } + + assert count >= 0 && count <= schema.getTotalFields(); + return count; + } + + /** {@inheritDoc} */ + @Override + public boolean isValid() { + boolean result = true; + byte[] array = new byte[schema.getWidth()]; + + for (int i = 0; i < schema.getWidth() && result; i++) { + row(i, array); + result = findDuplicateBits(schema, array) == 0; + } + + for (int i = 0; i < schema.getWidth() && result; i++) { + column(i, array); + result = findDuplicateBits(schema, array) == 0; + } + + for (int i = 0; i < schema.getWidth() && result; i += schema.getBlockWidth()) { + for (int j = 0; j < schema.getWidth() && result; j += schema.getBlockWidth()) { + block(i, j, array); + result = findDuplicateBits(schema, array) == 0; + } + } + + return result; + } + + /** {@inheritDoc} */ + @Override + public boolean isSetPossible(final int row, final int column, final byte value) { + if (!schema.areCoordsValid(row, column)) { + throw new IllegalArgumentException("Coordinates are not valid"); + } + if (!schema.isValueValid(value)) { + throw new IllegalArgumentException("Invalid value"); + } + if (value == schema.getUnsetValue()) { + return true; + } + + int free = getFreeMask(row, column); + return (free & (1 << value)) != 0; + } + + /** {@inheritDoc} */ + @Override + public void set(final int row, final int column, final byte value) { + if (!getSchema().areCoordsValid(row, column)) { + throw new IllegalArgumentException("Invalid row or column index"); + } + if (!getSchema().isValueValid(value)) { + throw new IllegalArgumentException("Invalid value"); + } + matrix[row][column] = value; + } + + /** {@inheritDoc} */ + @Override + public int hashCode() { + return Arrays.deepHashCode(matrix); + } + + /** {@inheritDoc} */ + @Override + public final boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof MatrixManager other)) { + return false; + } + + return Arrays.deepEquals(matrix, other.matrix); + } + + /** + * Returns a free mask for a given row. + * + * @param row The row index. + * @return The free mask for the row. + */ + int getRowFreeMask(final int row) { + byte[] array = new byte[schema.getWidth()]; + row(row, array); + return (~getNumberMask(schema, array)) & getSchema().getBitMask(); + } + + /** + * Returns a free mask for a given column. + * + * @param column The column index. + * @return The free mask for the column. + */ + int getColumnFreeMask(final int column) { + byte[] array = new byte[schema.getWidth()]; + column(column, array); + return (~getNumberMask(schema, array)) & getSchema().getBitMask(); + } + + /** + * Returns a free mask for a given block. + * + * @param row The row index of the block. + * @param column The column index of the block. + * @return The free mask for the block. + */ + int getBlockFreeMask(final int row, final int column) { + byte[] array = new byte[getSchema().getBlockWidth() * getSchema().getBlockWidth()]; + block(row, column, array); + return (~getNumberMask(schema, array)) & getSchema().getBitMask(); + } + + /** + * Returns a free mask for a given cell. + * + * @param row The row index of the cell. + * @param column The column index of the cell. + * @return The free mask for the cell. + * @throws IllegalArgumentException If the row or column index is invalid. + */ + public int getFreeMask(final int row, final int column) { + int free = schema.getBitMask(); + if (!schema.areCoordsValid(row, column)) { + throw new IllegalArgumentException("Invalid row or column index"); + } + free &= getRowFreeMask(row); + free &= getColumnFreeMask(column); + free &= getBlockFreeMask(row, column); + return free; + } + + /** + * Rounds a value down to the nearest multiple of the block width of the schema. + * + * @param value The value to round. + * @return The rounded value. + */ + final int roundToBlock(final int value) { + return value - (value % schema.getBlockWidth()); + } + + /** + * Copies a row of the schema into the target byte array. + * + * @param index The index of the row to copy. + * @param target The target byte array to copy the row into. + * @throws IllegalArgumentException If the length of the target array does not match the width of + * the schema. + */ + final void row(final int index, final byte[] target) { + if (target.length != schema.getWidth()) { + throw new IllegalArgumentException("Array length does not match schema width"); + } + System.arraycopy(matrix[index], 0, target, 0, schema.getWidth()); + } + + /** + * Copies a column of the schema into the target byte array. + * + * @param index The index of the column to copy. + * @param target The target byte array to copy the column into. + * @throws IllegalArgumentException If the length of the target array does not match the width of + * the schema. + */ + final void column(final int index, final byte[] target) { + if (target.length != schema.getWidth()) { + throw new IllegalArgumentException("Target array length does not match schema width"); + } + for (int row = 0; row < schema.getWidth(); row++) { + target[row] = matrix[row][index]; + } + } + + /** + * Copies a block of the schema into the target byte array. + * + * @param row The row index of the block to copy. + * @param column The column index of the block to copy. + * @param target The target byte array to copy the block into. + * @throws IllegalArgumentException If the length of the target array does not match the width of + * the schema, or if the specified coordinates are invalid for the schema. + */ + final void block(final int row, final int column, final byte[] target) { + if (target.length != schema.getWidth()) { + throw new IllegalArgumentException("Target array length does not match schema width"); + } + if (!getSchema().areCoordsValid(row, column)) { + throw new IllegalArgumentException("Invalid coordinates for schema"); + } + + int targetIndex = 0; + int roundRow = roundToBlock(row); + int roundColumn = roundToBlock(column); + for (int i = 0; i < schema.getBlockWidth(); i++) { + for (int j = 0; j < schema.getBlockWidth(); j++) { + target[targetIndex++] = matrix[roundRow + i][roundColumn + j]; + } + } + } + + /** + * Finds the cell with the least number of free values in the Sudoku matrix. + * + * @param rowColumnResult an array to store the row and column indices of the least free cell + * @return a FreeCellResult representing the result of the search + */ + public FreeCellResult findLeastFreeCell(final int[] rowColumnResult) { + int minimumBits = -1; + int minimumRow = -1; + int minimumColumn = -1; + + final int width = getSchema().getWidth(); + final byte unset = getSchema().getUnsetValue(); + boolean cellFound = false; + + for (int row = 0; row < width && !cellFound; row++) { + int rowMask = getRowFreeMask(row); + if (rowMask == 0) { + continue; + } + int[] result = findMinimumFreeCellInRow(row, unset); + int bits = result[2]; + if (bits == 0) { + return CONTRADICTION; + } + + assert bits <= width; + + if (minimumBits == -1 || bits < minimumBits) { + minimumRow = row; + minimumColumn = result[1]; + minimumBits = bits; + if (minimumBits == 1) { + cellFound = true; + } + } + } + + rowColumnResult[0] = minimumRow; + rowColumnResult[1] = minimumColumn; + + return minimumBits != -1 ? FOUND : NONE_FREE; + } + + private int[] findMinimumFreeCellInRow(final int row, final byte unset) { + final int width = getSchema().getWidth(); + int minimumBits = -1; + int minimumColumn = -1; + int free; + + for (int column = 0; column < width; column++) { + if (get(row, column) != unset) { + continue; + } + free = getFreeMask(row, column); + int bits = Integer.bitCount(free); + + if (bits != 0 && (minimumBits == -1 || bits < minimumBits)) { + minimumColumn = column; + minimumBits = bits; + } + } + + return new int[] {row, minimumColumn, minimumBits}; + } + + /** + * Enumeration representing the result of finding the least free cell in a Sudoku puzzle. It has + * three possible values: + * + *

+ */ + public enum FreeCellResult { + FOUND, + NONE_FREE, + CONTRADICTION + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/Schema.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/Schema.java new file mode 100644 index 0000000..f095a18 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/Schema.java @@ -0,0 +1,91 @@ +package ch.zhaw.pm2.amongdigits.utils.schema; + +/** + * An interface representing a schema for a sudoku-like game. A schema defines the size and + * structure of the grid, as well as the valid values that can be entered in each field of the grid. + */ +public interface Schema { + + /** + * Returns the minimum valid value that can be entered in any field of the schema. + * + * @return the minimum valid value + */ + byte getMinimumValue(); + + /** + * Returns the maximum valid value that can be entered in any field of the schema. + * + * @return the maximum valid value + */ + byte getMaximumValue(); + + /** + * Returns the value that represents an unset field in the schema. + * + * @return the value that represents an unset field + */ + byte getUnsetValue(); + + /** + * Returns the width of the schema, i.e. the number of fields in each row and column of the grid. + * + * @return the width of the schema + */ + int getWidth(); + + /** + * Returns the width of a block in the schema, i.e. the number of fields in each row and column of + * a block of the grid. + * + * @return the width of a block in the schema + */ + int getBlockWidth(); + + /** + * Returns the total number of fields in the schema, i.e. the number of fields in the entire grid. + * + * @return the total number of fields in the schema + */ + int getTotalFields(); + + /** + * Returns the number of blocks in the schema, i.e. the number of sub-grids in the grid. + * + * @return the number of blocks in the schema + */ + int getBlockCount(); + + /** + * Returns a bit mask representing the valid values for the fields in the schema. The i-th bit of + * the mask is set if the value i+1 is a valid value for the schema. + * + * @return a bit mask representing the valid values for the fields in the schema + */ + int getBitMask(); + + /** + * Returns whether the given value is a valid value for the schema. + * + * @param value the value to check + * @return true if the value is valid, false otherwise + */ + boolean isValueValid(byte value); + + /** + * Returns whether the given bit mask is a valid bit mask for the schema. + * + * @param bitMask the bit mask to check + * @return true if the bit mask is valid, false otherwise + */ + boolean isBitMaskValid(int bitMask); + + /** + * Returns whether the given coordinates are valid for the schema. + * + * @param row the row index (0-based) + * @param column the column index (0-based) + * @return true if the coordinates are valid, false otherwise + */ + boolean areCoordsValid(int row, int column); +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/SchemaManager.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/SchemaManager.java new file mode 100644 index 0000000..af9b857 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/SchemaManager.java @@ -0,0 +1,117 @@ +package ch.zhaw.pm2.amongdigits.utils.schema; + +class SchemaManager implements Schema { + + private final byte unsetValue; + private final byte minimumValue; + private final byte maximumValue; + private final int width; + private final int blockWidth; + private final int totalFields; + private final int blockCount; + private final int bitMask; + + SchemaManager( + final byte unsetValue, + final byte minimumValue, + final byte maximumValue, + final int width, + final int blockWidth) { + if (minimumValue <= unsetValue && unsetValue <= maximumValue) { + throw new IllegalArgumentException( + "Maximum value must be greater than unset value and unset value must be greater than minimum value"); + } + this.unsetValue = unsetValue; + + if (maximumValue - minimumValue + 1 != width) { + throw new IllegalArgumentException( + "Maximum value minus minimum value plus one must be equal to width"); + } + this.minimumValue = minimumValue; + this.maximumValue = maximumValue; + + if (width != blockWidth * blockWidth) { + throw new IllegalArgumentException("Width must be equal to block width squared"); + } + if (width <= 0) { + throw new IllegalArgumentException("Width must be greater than zero"); + } + if (width % blockWidth != 0) { + throw new IllegalArgumentException("Width must be a multiple of block width"); + } + this.width = width; + this.blockWidth = blockWidth; + this.totalFields = width * width; + this.blockCount = width / blockWidth; + + int bitMaskCounter = 0; + for (int i = minimumValue; i <= maximumValue; i++) { + bitMaskCounter |= 1 << i; + } + this.bitMask = bitMaskCounter; + } + + /** {@inheritDoc} */ + @Override + public byte getMinimumValue() { + return minimumValue; + } + + /** {@inheritDoc} */ + @Override + public byte getMaximumValue() { + return maximumValue; + } + + /** {@inheritDoc} */ + @Override + public byte getUnsetValue() { + return unsetValue; + } + + /** {@inheritDoc} */ + @Override + public int getWidth() { + return width; + } + + /** {@inheritDoc} */ + @Override + public int getBlockWidth() { + return blockWidth; + } + + /** {@inheritDoc} */ + @Override + public int getTotalFields() { + return totalFields; + } + + /** {@inheritDoc} */ + @Override + public int getBlockCount() { + return blockCount; + } + + /** {@inheritDoc} */ + @Override + public int getBitMask() { + return bitMask; + } + + /** {@inheritDoc} */ + @Override + public boolean isValueValid(final byte value) { + return value == unsetValue || (value >= minimumValue && value <= maximumValue); + } + + @Override + public boolean isBitMaskValid(final int bitMask) { + return (bitMask & (~this.bitMask)) == 0; + } + + @Override + public boolean areCoordsValid(final int row, final int column) { + return row >= 0 && row < width && column >= 0 && column < width; + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/SchemaTypes.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/SchemaTypes.java new file mode 100644 index 0000000..cbc7e28 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/schema/SchemaTypes.java @@ -0,0 +1,23 @@ +package ch.zhaw.pm2.amongdigits.utils.schema; + +import java.util.List; + +/** This class provides a set of predefined {@link Schema} types. */ +public final class SchemaTypes { + + /** A 9x9 {@link Schema} type. */ + public static final Schema SCHEMA_9X9 = new SchemaManager((byte) 0, (byte) 1, (byte) 9, 9, 3); + + private SchemaTypes() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Returns a list of all available {@link Schema} types. + * + * @return a list of all available {@link Schema} types + */ + public static List getSchemaTypes() { + return List.of(SCHEMA_9X9); + } +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/sudoku/Sudoku.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/sudoku/Sudoku.java new file mode 100644 index 0000000..acc7a26 --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/sudoku/Sudoku.java @@ -0,0 +1,20 @@ +package ch.zhaw.pm2.amongdigits.utils.sudoku; + +import ch.zhaw.pm2.amongdigits.utils.matrix.Matrix; + +/** + * The {@code Sudoku} interface represents the Sudoku game board, which extends the {@link + * ch.zhaw.pm2.amongdigits.utils.matrix.Matrix Matrix} interface. It includes an additional method + * to set a cell as writable or not. + */ +public interface Sudoku extends Matrix { + + /** + * Sets the specified cell as writable or not. + * + * @param row the row index of the cell to be set + * @param column the column index of the cell to be set + * @param writable {@code true} to set the cell as writable, {@code false} to set it as read-only + */ + void setWritable(int row, int column, boolean writable); +} diff --git a/src/main/java/ch/zhaw/pm2/amongdigits/utils/sudoku/SudokuManager.java b/src/main/java/ch/zhaw/pm2/amongdigits/utils/sudoku/SudokuManager.java new file mode 100644 index 0000000..ddb482b --- /dev/null +++ b/src/main/java/ch/zhaw/pm2/amongdigits/utils/sudoku/SudokuManager.java @@ -0,0 +1,55 @@ +package ch.zhaw.pm2.amongdigits.utils.sudoku; + +import ch.zhaw.pm2.amongdigits.utils.matrix.MatrixManager; +import ch.zhaw.pm2.amongdigits.utils.schema.Schema; + +/** + * A class for managing a Sudoku puzzle grid as a matrix, with the ability to set and get values for + * cells and to mark cells as writable or read-only. + */ +public class SudokuManager extends MatrixManager implements Sudoku { + + private boolean[][] writeable; + + /** + * Constructs a new SudokuManager object with the specified schema. + * + * @param schema the schema for the Sudoku puzzle. + */ + public SudokuManager(final Schema schema) { + super(schema); + int width = schema.getWidth(); + writeable = new boolean[width][width]; + for (int i = 0; i < width; i++) { + for (int j = 0; j < width; j++) { + setWritable(i, j, true); + } + } + } + + /** + * Clones the specified SudokuManager object. + * + * @param sudokuManager the SudokuManager object to clone. + * @return a clone of the specified SudokuManager object. + */ + static SudokuManager clone(final SudokuManager sudokuManager) { + SudokuManager clone = new SudokuManager(sudokuManager.getSchema()); + clone.setAll(sudokuManager.getAll()); + clone.writeable = sudokuManager.writeable.clone(); + + return clone; + } + + /** + * Sets whether the specified cell is writable or read-only. + * + * @param row the row index of the cell. + * @param column the column index of the cell. + * @param set whether the cell should be set as writable (true) or read-only (false). + */ + @Override + public final void setWritable(final int row, final int column, final boolean set) { + writeable[row][column] = set; + } +} diff --git a/src/main/resources/css/darkMode.css b/src/main/resources/css/darkMode.css new file mode 100644 index 0000000..5d8626b --- /dev/null +++ b/src/main/resources/css/darkMode.css @@ -0,0 +1,114 @@ +.root { + -fx-background-color: #1c1c1c; + -fx-text-fill: white; + -fx-base: black; +} + +.button { + -fx-background-color: grey; + -fx-text-fill: white; +} + +.button:hover { + -fx-background-color: lightgrey; + -fx-text-fill: black; +} + +.choice-box { + -fx-background-color: grey; + -fx-text-fill: white; +} + +.choice-box .label:hover { + -fx-background-color: lightgrey; + -fx-text-fill: black; +} + +.text-field { + -fx-background-color: darkgrey; + -fx-text-inner-color: white; +} + +.check-box { + -fx-text-fill: white; +} + +.label { + -fx-text-fill: white; +} + +GridPane Line { + -fx-stroke: white; +} + +GridPane Label { + -fx-border-color: white; +} + +GridPane GridPane Label { + -fx-border-width: 0; +} + +.sudokuGridSelectedRowAndColum { + -fx-background-color: grey; +} + +.sudokuLabelGiven { + -fx-text-fill: white; +} + +-sudokuLabelCorrect { + -fx-text-fill: green; +} + +.sudokuLabelIncorrect { + -fx-text-fill: red; +} + +.toggle-button { + -fx-background-color: grey; + -fx-text-fill: white; +} + +.toggle-button:hover { + -fx-background-color: lightgrey; + -fx-text-fill: black; +} + +.toggle-button:selected { + -fx-background-color: lightgrey; + -fx-text-fill: black; +} + +.dialog-pane { + -fx-border-color: black; + -fx-border-width: 2px; +} + +/**Customization of The Bar where the buttons are located**/ +.dialog-pane > .button-bar > .container { + +} + +/**Alert Content**/ +.dialog-pane > .content.label { + -fx-padding: 0.5em 0.5em 0.5em 0.5em; + -fx-font-size: 15px; +} + +.dialog-pane .header-panel .label { + -fx-background-radius: 10px; + -fx-font-size: 40px; +} + + +/**Customization of Buttons**/ +.dialog-pane .button { + -fx-wrap-text: true; + -fx-effect: dropshadow(three-pass-box, black, 10.0, 0.0, 0.0, 0.0); + -fx-cursor: hand; +} + +.dialog-pane .button:hover { + -fx-font-weight: bold; +} diff --git a/src/main/resources/css/lightMode.css b/src/main/resources/css/lightMode.css new file mode 100644 index 0000000..8f8ba77 --- /dev/null +++ b/src/main/resources/css/lightMode.css @@ -0,0 +1,119 @@ +.root { + -fx-background-color: #ffffff; + -fx-text-fill: black; + -fx-base: white; +} + +.button { + -fx-background-color: indianred; + -fx-text-fill: black; +} + +.button:hover { + -fx-background-color: black; + -fx-text-fill: white; +} + +.choice-box { + -fx-background-color: indianred; + -fx-text-fill: black; +} + +.choice-box .label:hover { + -fx-background-color: black; + -fx-text-fill: white; +} + +.text-field { + -fx-background-color: indianred; + -fx-text-inner-color: black; +} + +.check-box { + -fx-text-fill: black; +} + +.label { + -fx-text-fill: black; +} + +GridPane Line { + -fx-stroke: black; +} + +GridPane Label { + -fx-border-color: black; +} + +GridPane GridPane Label { + -fx-border-width: 0; +} + +.sudokuGridSelectedField { + -fx-background-color: black; + -fx-text-fill: blue; +} + +.sudokuGridSelectedRowAndColum { + -fx-background-color: grey; +} + +.sudokuLabelGiven { + -fx-text-fill: white; +} + +-sudokuLabelCorrect { + -fx-text-fill: green; +} + +.sudokuLabelIncorrect { + -fx-text-fill: red; +} + +.toggle-button { + -fx-background-color: indianred; + -fx-text-fill: black; +} + +.toggle-button:hover { + -fx-background-color: black; + -fx-text-fill: white; +} + +.toggle-button:selected { + -fx-background-color: black; + -fx-text-fill: white; +} + +.dialog-pane { + -fx-border-color: black; + -fx-border-width: 2px; +} + +/**Customization of The Bar where the buttons are located**/ +.dialog-pane > .button-bar > .container { + +} + +/**Alert Content**/ +.dialog-pane > .content.label { + -fx-padding: 0.5em 0.5em 0.5em 0.5em; + -fx-font-size: 15px; +} + +.dialog-pane .header-panel .label { + -fx-background-radius: 10px; + -fx-font-size: 40px; +} + + +/**Customization of Buttons**/ +.dialog-pane .button { + -fx-wrap-text: true; + -fx-effect: dropshadow(three-pass-box, black, 10.0, 0.0, 0.0, 0.0); + -fx-cursor: hand; +} + +.dialog-pane .button:hover { + -fx-font-weight: bold; +} diff --git a/src/main/resources/languages/MessagesBundle.properties b/src/main/resources/languages/MessagesBundle.properties new file mode 100644 index 0000000..4c5e188 --- /dev/null +++ b/src/main/resources/languages/MessagesBundle.properties @@ -0,0 +1,57 @@ +main_menu=Main Menu +new_game=New Game +retry_game=Try again +beginner=Beginner +easy=Easy +medium=Medium +hard=Hard +expert=Expert +load_your_sudoku=Load your sudoku +settings=Settings +statistics=Statistics +enable_dark_mode=Enable dark mode +check_time=Check time +check_mistakes=Check mistakes +realtime_feedback=Realtime feedback +reset_statistics=Reset statistics +your_stats=Your stats +games_started=Games started +games_won=Games won +time_played=Total time +mistakes_made=Total Mistakes +best_time=Best time +custom=Custom +time_label=Time: +mistakes_label=Mistakes: +help_text=To fill a field, click on the field and enter a number. \ + If you are not sure, you can leave the field empty or use a pencil to insert possible numbers. \ + To remove a typed number, you can press DELETE or BACKSPACE. \ + Have fun playing! +game_title=Among Digits +won_text=You won! +won_title=Game won! +lost_text=You lost! +lost_title=Game lost! +help_title=Help +upload_failed=Upload failed +upload_success=Upload successful +upload_success_message=The Sudoku is valid and has been uploaded. You can play it in the Challenges Section +selected_file_invalid_exception=The selected file is invalid. Reason: %s +choose_file_title=Choose your own Sudoku File +selected_file_sudoku_invalid_exception=The uploaded Sudoku is invalid. Reason: %s +no_unique_solution_exception=Sudoku must have a unique solution to be considered valid. +no_txt_file_exception=Given file must be a .txt file +not_parseable_exception=Not able to parse given Sudoku File %s +wrong_grid_size_exception=Sudoku Grid does not match the expected Grid Size of %s +sudoku_not_compatible_exception=Given Sudoku Grids are not compatible as not all digits are matching +invalid_grid_cell_exception=File Grid can only contain non-zero digits, a grid separator of type %s and empty grid cells of type %s +sudoku_exists_exception=The same Sudoku with the name %s exists already +sudoku_upload_io_exception=Sudoku could not be uploaded due to %s +challenges_title=Challenges +pre_generated=Pre generated Challenges +user_generated=User generated Challenges +play_selected_challenge=Play selected Challenge +sudoku_load_failed_title=Sudoku cannot be played +sudoku_load_failed=There is a problem loading this Sudoku. Please try again later or call the support if this problem persists. +upload_help_title=Upload Sudoku Help +upload_help=Upload your own Sudoku File by creating a .txt File which contains the following 9x9 structure: diff --git a/src/main/resources/languages/MessagesBundle_de.properties b/src/main/resources/languages/MessagesBundle_de.properties new file mode 100644 index 0000000..fa2c2d9 --- /dev/null +++ b/src/main/resources/languages/MessagesBundle_de.properties @@ -0,0 +1,55 @@ +main_menu=Hauptmen\u00fc +new_game=Neues Spiel +retry_game=Erneut versuchen +beginner=Beginner +easy=Einfach +medium=Mittel +hard=Schwer +expert=Experte +load_your_sudoku=Lade dein Sudoku +settings=Einstellungen +statistics=Statistiken +enable_dark_mode=Dunkler Modus +check_time=Zeit kontrollieren +check_mistakes=Fehler prüfen +realtime_feedback=Echtzeit-Feedback +reset_statistics=Statistiken zur\u00fccksetzen +your_stats=Deine Statistiken +games_started=Spiele gestartet +games_won=Spiele gewonnen +time_played=Totale Spielzeit +mistakes_made=Fehler gemacht +best_time=Beste Zeit +custom=Eigene +time_label=Zeit: +mistakes_label=Fehler: +help_text=Um ein Feld zu f\u00fcllen, klicken Sie auf das Feld und geben Sie eine Zahl ein. \ + Wenn Sie sich nicht sicher sind, k\u00f6nnen Sie das Feld leer lassen oder mittels Bleistift m\u00f6gliche Zahlen einf\u00fcgen. \ + Um eine Nummer aus dem Sudoku zu l\u00f6schen k\u00f6nnen sie die DELETE oder BACKSPACE Taste dr\u00fccken. \ + Viel Spass beim Spielen! +game_title=Among Digits +won_text=Du hast gewonnen! +won_title=Spiel gewonnen! +help_title=Hilfe +upload_failed=Hochladen fehlgeschlagen +upload_success=Hochladen erfolgreich +upload_success_message=Das Sudoku ist gültig und wurde hochgeladen. Du kannst es in der Sektion Herausforderungen spielen +selected_file_invalid_exception=Die ausgewählte Datei ist ungültig. Grund: %s +choose_file_title=Wähle deine eigene Sudoku-Datei +selected_file_sudoku_invalid_exception=Das hochgeladene Sudoku ist ungültig. Grund: %s +no_unique_solution_exception=Ein Sudoku muss eine eindeutige Lösung haben, um als gültig zu gelten. +no_txt_file_exception=Die angegebene Datei muss eine .txt-Datei sein +not_parseable_exception=Kann die gegebene Sudoku-Datei %s nicht lesen +wrong_grid_size_exception=Das Sudoku-Raster entspricht nicht der erwarteten Rastergröße von %s +sudoku_not_compatible_exception=Die gegebenen Sudoku-Gitter sind nicht kompatibel, da nicht alle Ziffern übereinstimmen +invalid_grid_cell_exception=File Grid kann nur Nicht-Null-Ziffern, ein Gittertrennzeichen vom Typ %s und leere Gitterzellen vom Typ %s enthalten +sudoku_exists_exception=Das gleiche Sudoku mit dem Namen %s existiert bereits +sudoku_upload_io_exception=Sudoku could not be uploaded due to %s +challenges_title=Herausforderungen +pre_generated=Vordefinierte Herausforderungen +user_generated=Benutzerdefinierte Herausforderungen +play_selected_challenge=Spiele ausgew\u00E4hlte Herausforderung +sudoku_load_failed_title=Sudoku kann nicht gespielt werden +sudoku_load_failed=Es gab ein Problem beim Laden dieses Sudokus. Bitte probiere es sp\u00E4ter nochmals oder kontaktiere den Support. +upload_help_title=Sudoku hochladen Hilfe +upload_help=Laden deine eigene Sudoku-Datei hoch, indem du eine .txt-Datei erstellen, welche die folgende 9x9 Struktur enthält: diff --git a/src/main/resources/languages/MessagesBundle_en.properties b/src/main/resources/languages/MessagesBundle_en.properties new file mode 100644 index 0000000..b9910cf --- /dev/null +++ b/src/main/resources/languages/MessagesBundle_en.properties @@ -0,0 +1,55 @@ +main_menu=Main Menu +new_game=New Game +retry_game=Try again +beginner=Beginner +easy=Easy +medium=Medium +hard=Hard +expert=Expert +load_your_sudoku=Load your sudoku +settings=Settings +statistics=Statistics +enable_dark_mode=Toggle dark mode +check_time=Check time +check_mistakes=Check mistakes +realtime_feedback=Realtime feedback +reset_statistics=Reset statistics +your_stats=Your stats +time_label=Time: +mistakes_label=Mistakes: +help_text=To fill a field, click on the field and enter a number. \ + If you are not sure, you can leave the field empty or use a pencil to insert possible numbers. \ + To remove a typed number, you can press DELETE or BACKSPACE. \ + Have fun playing! +games_started=Games started +games_won=Games won +time_played=Time played +mistakes_made=Mistakes made +best_time=Best time +custom=Custom +game_title=Among Digits +won_text=You won! +won_title=Game won! +help_title=Help +upload_failed=Upload failed +upload_success=Upload successful +upload_success_message=The Sudoku is valid and has been uploaded. You can play it in the Challenges Section +selected_file_invalid_exception=The selected file is invalid. Reason: %s +choose_file_title=Choose your own Sudoku File +selected_file_sudoku_invalid_exception=The uploaded Sudoku is invalid. Reason: %s +no_unique_solution_exception=Sudoku must have a unique solution to be considered valid. +no_txt_file_exception=Given file must be a .txt file +not_parseable_exception=Not able to parse given Sudoku File %s +wrong_grid_size_exception=Sudoku Grid does not match the expected Grid Size of %s +sudoku_not_compatible_exception=Given Sudoku Grids are not compatible as not all digits are matching +invalid_grid_cell_exception=File Grid can only contain non-zero digits, a grid separator of type %s and empty grid cells of type %s +sudoku_exists_exception=The same Sudoku with the name %s exists already +sudoku_upload_io_exception=Sudoku could not be uploaded due to %s +challenges_title=Challenges +pre_generated=Pre generated Challenges +user_generated=User generated Challenges +play_selected_challenge=Play selected Challenge +sudoku_load_failed_title=Sudoku cannot be played +sudoku_load_failed=There is a problem loading this Sudoku. Please try again later or call the support if this problem persists. +upload_help_title=Upload Sudoku Help +upload_help=Upload your own Sudoku File by creating a .txt File which contains the following 9x9 structure: diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..d91ec6a --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %gray(%d{HH:mm:ss}) %highlight(%-5level) [%thread] %gray(%logger{36}) - %msg%n + + + + + + + + diff --git a/src/main/resources/media/Exit.gif b/src/main/resources/media/Exit.gif new file mode 100644 index 0000000..b7eb01e Binary files /dev/null and b/src/main/resources/media/Exit.gif differ diff --git a/src/main/resources/media/GameLost.gif b/src/main/resources/media/GameLost.gif new file mode 100644 index 0000000..9ebe0f6 Binary files /dev/null and b/src/main/resources/media/GameLost.gif differ diff --git a/src/main/resources/media/GameMenu.gif b/src/main/resources/media/GameMenu.gif new file mode 100644 index 0000000..2f22e9a Binary files /dev/null and b/src/main/resources/media/GameMenu.gif differ diff --git a/src/main/resources/media/GameWon.gif b/src/main/resources/media/GameWon.gif new file mode 100644 index 0000000..6c5fa23 Binary files /dev/null and b/src/main/resources/media/GameWon.gif differ diff --git a/src/main/resources/media/Help.gif b/src/main/resources/media/Help.gif new file mode 100644 index 0000000..21778e4 Binary files /dev/null and b/src/main/resources/media/Help.gif differ diff --git a/src/main/resources/media/MainMenu.gif b/src/main/resources/media/MainMenu.gif new file mode 100644 index 0000000..c621c91 Binary files /dev/null and b/src/main/resources/media/MainMenu.gif differ diff --git a/src/main/resources/media/Settings.gif b/src/main/resources/media/Settings.gif new file mode 100644 index 0000000..f23e71c Binary files /dev/null and b/src/main/resources/media/Settings.gif differ diff --git a/src/main/resources/media/Statistics.gif b/src/main/resources/media/Statistics.gif new file mode 100644 index 0000000..1cab37f Binary files /dev/null and b/src/main/resources/media/Statistics.gif differ diff --git a/src/main/resources/media/Upload.gif b/src/main/resources/media/Upload.gif new file mode 100644 index 0000000..f8fae93 Binary files /dev/null and b/src/main/resources/media/Upload.gif differ diff --git a/src/main/resources/media/UploadFailed.gif b/src/main/resources/media/UploadFailed.gif new file mode 100644 index 0000000..35a7063 Binary files /dev/null and b/src/main/resources/media/UploadFailed.gif differ diff --git a/src/main/resources/media/UploadHelp.gif b/src/main/resources/media/UploadHelp.gif new file mode 100644 index 0000000..8521492 Binary files /dev/null and b/src/main/resources/media/UploadHelp.gif differ diff --git a/src/main/resources/media/game-music.mp3 b/src/main/resources/media/game-music.mp3 new file mode 100644 index 0000000..ca991b7 Binary files /dev/null and b/src/main/resources/media/game-music.mp3 differ diff --git a/src/main/resources/media/menu-music.mp3 b/src/main/resources/media/menu-music.mp3 new file mode 100644 index 0000000..0d79143 Binary files /dev/null and b/src/main/resources/media/menu-music.mp3 differ diff --git a/src/main/resources/media/world.png b/src/main/resources/media/world.png new file mode 100644 index 0000000..1604d42 Binary files /dev/null and b/src/main/resources/media/world.png differ diff --git a/src/main/resources/properties/defaultsettings.properties b/src/main/resources/properties/defaultsettings.properties new file mode 100644 index 0000000..48787f7 --- /dev/null +++ b/src/main/resources/properties/defaultsettings.properties @@ -0,0 +1,7 @@ +#Fri May 05 11:19:04 CEST 2023 +darkMode=false +checkMistakes=true +checkTime=true +realtimeFeedback=true +language=de +cssFileString=/css/darkMode.css diff --git a/src/main/resources/properties/defaultstatistics.properties b/src/main/resources/properties/defaultstatistics.properties new file mode 100644 index 0000000..883e54b --- /dev/null +++ b/src/main/resources/properties/defaultstatistics.properties @@ -0,0 +1,32 @@ +#Fri May 05 11:19:04 CEST 2023 +beginnerGameStarted=0 +beginnerGameMistakes=0 +beginnerGameBestTime=0 +beginnerGameTimePlayed=0 +beginnerGameWon=0 +easyGameStarted=0 +easyGameMistakes=0 +easyGameBestTime=0 +easyGameTimePlayed=0 +easyGameWon=0 +mediumGameStarted=0 +mediumGameMistakes=0 +mediumGameBestTime=0 +mediumGameTimePlayed=0 +mediumGameWon=0 +hardGameStarted=0 +hardGameMistakes=0 +hardGameBestTime=0 +hardGameTimePlayed=0 +hardGameWon=0 +expertGameStarted=0 +expertGameMistakes=0 +expertGameBestTime=0 +expertGameTimePlayed=0 +expertGameWon=0 +customGameStarted=0 +customGameMistakes=0 +customGameBestTime=0 +customGameTimePlayed=0 +customGameWon=0 + diff --git a/src/main/resources/sudokus/pre-generated/BEGINNER_Hyper_1.txt b/src/main/resources/sudokus/pre-generated/BEGINNER_Hyper_1.txt new file mode 100644 index 0000000..9344a24 --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/BEGINNER_Hyper_1.txt @@ -0,0 +1,19 @@ +234178--- +--93-5-2- +815-69374 +4-3621957 +-519--632 +9--53--8- +54281-769 +367-92--5 +19--562-3 +* +234178596 +679345128 +815269374 +483621957 +751984632 +926537481 +542813769 +367492815 +198756243 diff --git a/src/main/resources/sudokus/pre-generated/BEGINNER_Row row row your boat_1.txt b/src/main/resources/sudokus/pre-generated/BEGINNER_Row row row your boat_1.txt new file mode 100644 index 0000000..237ddad --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/BEGINNER_Row row row your boat_1.txt @@ -0,0 +1,19 @@ +12836759- +-542813-6 +-3-549-18 +-6-93-45- +5--41-732 +34-725-6- +--36941-- +49517--83 +612-53-4- +* +128367594 +954281376 +736549218 +267938451 +589416732 +341725869 +873694125 +495172683 +612853947 diff --git a/src/main/resources/sudokus/pre-generated/EASY_Greenhorn_1.txt b/src/main/resources/sudokus/pre-generated/EASY_Greenhorn_1.txt new file mode 100644 index 0000000..d2c6a55 --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/EASY_Greenhorn_1.txt @@ -0,0 +1,19 @@ +-472--358 +1-9---472 +23-457-9- +78-913564 +9--64-78- +364-7892- +-9--6---7 +4-6---8-- +873---649 +* +647291358 +159386472 +238457196 +782913564 +915642783 +364578921 +591864237 +426739815 +873125649 diff --git a/src/main/resources/sudokus/pre-generated/EASY_Lemon Squeezy_1.txt b/src/main/resources/sudokus/pre-generated/EASY_Lemon Squeezy_1.txt new file mode 100644 index 0000000..02f7abb --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/EASY_Lemon Squeezy_1.txt @@ -0,0 +1,19 @@ +9--46---1 +5-4---2-9 +-361-9--- +65924-817 +4---7-623 +7-3-1--95 +1678-4-5- +84259---6 +39572--84 +* +978462531 +514387269 +236159748 +659243817 +481975623 +723618495 +167834952 +842591376 +395726184 \ No newline at end of file diff --git a/src/main/resources/sudokus/pre-generated/EXPERT_Nightmare_1.txt b/src/main/resources/sudokus/pre-generated/EXPERT_Nightmare_1.txt new file mode 100644 index 0000000..9c1630e --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/EXPERT_Nightmare_1.txt @@ -0,0 +1,19 @@ +4--5-1--- +------7-1 +--12-3--- +--8--93-7 +147------ +--5------ +95---4--6 +---93---2 +-6-1---3- +* +486571923 +239648751 +571293648 +628459317 +147362895 +395817264 +953724186 +814936572 +762185439 diff --git a/src/main/resources/sudokus/pre-generated/EXPERT_The Maze_1.txt b/src/main/resources/sudokus/pre-generated/EXPERT_The Maze_1.txt new file mode 100644 index 0000000..3de8b9f --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/EXPERT_The Maze_1.txt @@ -0,0 +1,19 @@ +1-78----- +----2-3-- +-6---5--- +-----217- +---7---3- +-8-5--4-2 +6------1- +--3---964 +--4--75-- +* +127843659 +459126387 +368975241 +935482176 +241769835 +786531492 +692354718 +573218964 +814697523 diff --git a/src/main/resources/sudokus/pre-generated/HARD_Diabolical Sudoku_1.txt b/src/main/resources/sudokus/pre-generated/HARD_Diabolical Sudoku_1.txt new file mode 100644 index 0000000..6e2b0ef --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/HARD_Diabolical Sudoku_1.txt @@ -0,0 +1,19 @@ +-75------ +--9-82-5- +-8--6-4-- +-3--4-6-9 +-------71 +--8--5-4- +1---748-6 +-9-----1- +-2-9-1--- +* +275413968 +649782153 +381569427 +532147689 +964328571 +718695342 +153274896 +497836215 +826951734 diff --git a/src/main/resources/sudokus/pre-generated/HARD_Inferno_1.txt b/src/main/resources/sudokus/pre-generated/HARD_Inferno_1.txt new file mode 100644 index 0000000..42e2608 --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/HARD_Inferno_1.txt @@ -0,0 +1,19 @@ +--42--7-- +--914---3 +3-5------ +----682-7 +6---32-59 +-9--1-3-- +-5-7-1--- +42------8 +----5---- +* +164283795 +279145683 +385679142 +543968217 +617432859 +892517364 +958721436 +421396578 +736854921 diff --git a/src/main/resources/sudokus/pre-generated/MEDIUM_Conundrum_1.txt b/src/main/resources/sudokus/pre-generated/MEDIUM_Conundrum_1.txt new file mode 100644 index 0000000..6d856af --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/MEDIUM_Conundrum_1.txt @@ -0,0 +1,19 @@ +-92158--- +-863----5 +4--796128 +---43-9-2 +5-9-1--4- +247--9--3 +---24136- +9----3-8- +-6--8--71 +* +792158436 +186324795 +453796128 +618437952 +539812647 +247569813 +875241369 +921673584 +364985271 diff --git a/src/main/resources/sudokus/pre-generated/MEDIUM_Gauntlet_1.txt b/src/main/resources/sudokus/pre-generated/MEDIUM_Gauntlet_1.txt new file mode 100644 index 0000000..71b4cd6 --- /dev/null +++ b/src/main/resources/sudokus/pre-generated/MEDIUM_Gauntlet_1.txt @@ -0,0 +1,19 @@ +86731---9 +3-4-2-6-8 +5-984-1-7 +---17--6- +13-----7- +6-8453--- +---7812-3 +74--39-8- +---5-47-- +* +867315429 +314927658 +529846137 +492178365 +135692874 +678453912 +956781243 +741239586 +283564791 diff --git a/src/main/resources/sudokus/upload/EASY_SUS Sudoku_1.txt b/src/main/resources/sudokus/upload/EASY_SUS Sudoku_1.txt new file mode 100644 index 0000000..d891f5c --- /dev/null +++ b/src/main/resources/sudokus/upload/EASY_SUS Sudoku_1.txt @@ -0,0 +1,19 @@ +93-2----1 +78--9-253 +4213-57-6 +143852-79 +------385 +-7-9--12- +314-2--68 +8-2619-3- +69-438--- +* +935276841 +786194253 +421385796 +143852679 +269741385 +578963124 +314527968 +852619437 +697438512 diff --git a/src/main/resources/sudokus/upload/EXPERT_Polish Cow_1.txt b/src/main/resources/sudokus/upload/EXPERT_Polish Cow_1.txt new file mode 100644 index 0000000..7e936dc --- /dev/null +++ b/src/main/resources/sudokus/upload/EXPERT_Polish Cow_1.txt @@ -0,0 +1,19 @@ +-183----- +-4--9---8 +76-5----- +-5--168-- +----38--1 +--7------ +--5-4---- +-------42 +------397 +* +218364975 +543197268 +769582413 +354716829 +692438751 +187925634 +975243186 +831679542 +426851397 diff --git a/src/main/resources/sudokus/upload/HARD_Sudoku from Simon_1.txt b/src/main/resources/sudokus/upload/HARD_Sudoku from Simon_1.txt new file mode 100644 index 0000000..c60e91b --- /dev/null +++ b/src/main/resources/sudokus/upload/HARD_Sudoku from Simon_1.txt @@ -0,0 +1,19 @@ +9---8-3-- +---25-7-- +-2-3----4 +-94------ +---73-56- +7-5-6-4-- +--78-39-- +--1-----3 +3-------2 +* +976481325 +143259786 +528376194 +694518237 +812734569 +735962418 +467823951 +251697843 +389145672 \ No newline at end of file diff --git a/src/main/resources/sudokus/upload/MEDIUM_Bitconneeeeeect_1.txt b/src/main/resources/sudokus/upload/MEDIUM_Bitconneeeeeect_1.txt new file mode 100644 index 0000000..5a50d6e --- /dev/null +++ b/src/main/resources/sudokus/upload/MEDIUM_Bitconneeeeeect_1.txt @@ -0,0 +1,19 @@ +-821--49- +3--5----- +1-46-8-2- +----842-7 +423--6--- +-189253-- +-314-78-9 +---8516-2 +8-5--9-4- +* +582173496 +396542178 +174698523 +659384217 +423716985 +718925364 +231467859 +947851632 +865239741 diff --git a/src/main/resources/views/Challenges.fxml b/src/main/resources/views/Challenges.fxml new file mode 100644 index 0000000..c54f5ba --- /dev/null +++ b/src/main/resources/views/Challenges.fxml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
diff --git a/src/main/resources/views/MainMenu.fxml b/src/main/resources/views/MainMenu.fxml new file mode 100644 index 0000000..466c3ac --- /dev/null +++ b/src/main/resources/views/MainMenu.fxml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+
diff --git a/src/main/resources/views/NewGameMenu.fxml b/src/main/resources/views/NewGameMenu.fxml new file mode 100644 index 0000000..302ba66 --- /dev/null +++ b/src/main/resources/views/NewGameMenu.fxml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+
+
diff --git a/src/main/resources/views/Settings.fxml b/src/main/resources/views/Settings.fxml new file mode 100644 index 0000000..f20fcae --- /dev/null +++ b/src/main/resources/views/Settings.fxml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/src/main/resources/views/Statistics.fxml b/src/main/resources/views/Statistics.fxml new file mode 100644 index 0000000..ca8b84f --- /dev/null +++ b/src/main/resources/views/Statistics.fxml @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/src/main/resources/views/SudokuGameView.fxml b/src/main/resources/views/SudokuGameView.fxml new file mode 100644 index 0000000..16bd0bf --- /dev/null +++ b/src/main/resources/views/SudokuGameView.fxml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + +