diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index b3cc2e0..84e4dbc 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -7,7 +7,7 @@ jobs: matrix: # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs os: [ubuntu-latest, windows-latest, macos-latest] - jdk: [11] + jdk: [11, 17, 21] steps: - uses: actions/checkout@v4 with: @@ -55,7 +55,7 @@ jobs: - name: Upload jar as artifact uses: actions/upload-artifact@v4 with: - name: software-challenge-gui-${{ github.sha }}-${{ matrix.os }} + name: software-challenge-gui-${{ github.sha }}-j${{ matrix.jdk }}-${{ matrix.os }} path: build/*.jar release: needs: [build, build-arm] @@ -64,7 +64,7 @@ jobs: steps: - uses: actions/download-artifact@v4 # https://github.com/actions/download-artifact with: - pattern: software-challenge-gui-${{ github.sha }}-* + pattern: software-challenge-gui-${{ github.sha }}-j11-* path: build merge-multiple: true - name: Release ${{ github.ref }} diff --git a/.gitmodules b/.gitmodules index 9bc842f..ae0d755 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,7 @@ path = backend url = https://github.com/software-challenge/backend shallow = true + branch = plugin/4-gewinnt [submodule ".idea"] path = .idea url = https://github.com/software-challenge/idea-config diff --git a/.kotlin/sessions/kotlin-compiler-13424373081474350713.salive b/.kotlin/sessions/kotlin-compiler-13424373081474350713.salive new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index b141e81..bbd46d0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## Software-Challenge Logo Grafischer Spieleserver der Software-Challenge Germany ![.github/workflows/gradle.yml](https://github.com/software-challenge/gui/workflows/.github/workflows/gradle.yml/badge.svg) Dies ist die Grafische Oberfläche für die Software-Challenge Germany, -seit Saison 2020/21 in Kotlin TornadoFX aufbauend auf JavaFX. +seit Saison 2020/21 in Kotlin TornadoFX, aufbauend auf JavaFX. Nutzerdokumentation: https://docs.software-challenge.de/server.html @@ -23,7 +23,7 @@ wird der Server auf diesem Port Verbindungen von Spielern erwarten. ### Kollaboration Unsere Commit-Messages folgen dem Muster `type(scope): summary` -(siehe [Karma Runner Konvention](http://karma-runner.github.io/6.2/dev/git-commit-msg.html)), +(siehe [Karma Runner Konvention](http://karma-runner.github.io/6.4/dev/git-commit-msg.html)), wobei die gängigen Scopes in [.dev/scopes.txt](.dev/scopes.txt) definiert werden. Nach dem Klonen mit git sollte dazu der hook aktiviert werden: @@ -31,7 +31,7 @@ Nach dem Klonen mit git sollte dazu der hook aktiviert werden: Um bei den Branches die Übersicht zu behalten, sollten diese ebenfalls nach der Konvention benannt werden, -z. B. könnte ein Branch mit einem Release-Fix für Gradle `chore/gradle/release-fix` heißen +z. B. könnte ein Branch mit einem Release-Fix für Gradle `build/gradle/release-fix` heißen und ein Branch, der ein neues Login-Feature zur GUI hinzufügt, `feat/gui-login`. Wenn die einzelnen Commits eines Pull Requests eigenständig funktionieren, @@ -40,15 +40,4 @@ ansonsten (gerade bei experimentier-Branches) ein squash merge, wobei der Titel des Pull Requests der Commit-Message entsprechen sollte. Detaillierte Informationen zu unserem Kollaborations-Stil -findet ihr in der [Kull Konvention](https://kull.jfischer.org). - -### Java-Versionen und Abhängigkeiten - -Aktuell können die Backend-docs nur mit JDK 8 gebaut werden, -dieses Projekt braucht jedoch für [tornadofx](https://github.com/edvin/tornadofx2) -mindestens Java 11. -Daher müssen die Releases separat gebaut werden. - -Tornadofx wird leider seit einigen Jahren nicht mehr entwickelt. -Wir schauen gerade wie es da weitergeht. -Eventuell ein eigener Fork. +findet ihr in der [Kull Konvention](https://kull.jfischer.org). \ No newline at end of file diff --git a/backend b/backend index e2a37e1..7b67e3a 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit e2a37e18b3ca4929003ed2d56e43d492ee9caff9 +Subproject commit 7b67e3a100c55ad7ea550217f242d52f805994c6 diff --git a/build.gradle.kts b/build.gradle.kts index c249214..b3a800a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,10 @@ import org.gradle.internal.os.OperatingSystem +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.util.Properties val minJavaVersion = JavaVersion.VERSION_11 +val targetJavaVersion = JavaVersion.current() // minJavaVersion can be set for compatibility plugins { val minJavaVersion = JavaVersion.VERSION_11 // Declared twice because plugins block has its own scope require(JavaVersion.current() >= minJavaVersion) { @@ -10,13 +12,13 @@ plugins { } application - kotlin("jvm") version "1.9.25" + kotlin("jvm") version "2.3.0" id("idea") id("org.openjfx.javafxplugin") version "0.1.0" - id("com.github.johnrengelman.shadow") version "6.1.0" + id("com.gradleup.shadow") version "9.1.0" - id("com.github.ben-manes.versions") version "0.47.0" - id("se.patrikerdes.use-latest-versions") version "0.2.18" + id("com.github.ben-manes.versions") version "0.53.0" + id("se.patrikerdes.use-latest-versions") version "0.2.19" } idea { @@ -34,7 +36,7 @@ val versionFromBackend by lazy { arrayOf("year", "minor", "patch").map { versions["socha.version.$it"].toString().toInt() }.joinToString(".") + suffix } -group = "sc.gui" +group = "software-challenge" version = try { Runtime.getRuntime().exec(arrayOf("git", "describe", "--tags")) .inputStream.reader().readText().trim().ifEmpty { null } @@ -44,7 +46,7 @@ version = try { println("Current version: $version (Java version: ${JavaVersion.current()})") application { - mainClassName = "sc.gui.GuiAppKt" // needs shadow-update which needs gradle update to 7.0 + mainClass.set("sc.gui.GuiAppKt") } repositories { @@ -55,34 +57,39 @@ repositories { maven("https://jitpack.io") } +// ./gradlew run -Pdebug for debug tools and logging val debug = project.hasProperty("debug") dependencies { implementation(kotlin("stdlib-jdk8")) implementation(kotlin("reflect")) - + + implementation(files("./gradle/tornadofx2-21e933fd41.jar")) // implementation("no.tornado", "tornadofx", "2.0.0-SNAPSHOT") { exclude("org.jetbrains.kotlin", "kotlin-reflect") } // implementation("com.github.software-challenge.tornadofx2", "tornadofx2", "2.0.0") // implementation("com.github.edvin", "tornadofx2", "master-SNAPSHOT") // implementation("com.github.edvin", "tornadofx2", "21e933fd41") - implementation(files("./gradle/tornadofx2-21e933fd41.jar")) - implementation("ch.qos.logback", "logback-classic", "1.5.18") - implementation("io.github.oshai", "kotlin-logging-jvm", "6.0.9") // TODO version 7 with kotlin 2 + implementation("ch.qos.logback", "logback-classic", "1.5.32") + implementation("io.github.oshai", "kotlin-logging-jvm", "8.0.01") implementation("software-challenge", "server") + implementation("software-challenge", "plugin2023") implementation("software-challenge", "plugin2024") implementation("software-challenge", "plugin2025") - implementation("software-challenge", "plugin") + implementation("software-challenge", "plugin2026") + implementation("software-challenge", "plugin2098") - if(debug) + if(debug) { + // hold Ctrl to view component hierarchy and bounds implementation("com.tangorabox", "component-inspector-fx", "1.1.0") + } } tasks { compileJava { - options.release.set(minJavaVersion.majorVersion.toInt()) + options.release.set(targetJavaVersion.majorVersion.toInt()) } processResources { if(!debug) @@ -92,14 +99,14 @@ tasks { } } withType { - kotlinOptions { - jvmTarget = minJavaVersion.toString() - freeCompilerArgs = listOf("-Xjvm-default=all") + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(targetJavaVersion.toString())) + //freeCompilerArgs.addAll("-jvm-default=all") } } withType { - manifest.attributes["Main-Class"] = application.mainClassName + manifest.attributes["Main-Class"] = application.mainClass.get() } javafx { @@ -107,13 +114,14 @@ tasks { val mods = mutableListOf( "javafx.base", "javafx.controls", "javafx.fxml", "javafx.web", "javafx.media", "javafx.swing" - ) // included because of tornadofx already + ) + // included because of tornadofx already // if(debug) mods.addAll(listOf("javafx.swing")) modules = mods } shadowJar { - destinationDirectory.set(buildDir) + destinationDirectory.set(layout.buildDirectory.asFile.get()) archiveClassifier.set( "${ OperatingSystem.current().familyName.replace( @@ -140,24 +148,25 @@ tasks { run.configure { dependsOn(backend.task(":server:makeRunnable")) - workingDir(buildDir.resolve("run")) + workingDir(layout.buildDirectory.asFile.get().resolve("run")) doFirst { workingDir.mkdirs() } args = System.getProperty("args", "").split(" ") } - val release by creating { + val release by registering { dependsOn(clean, check) group = "distribution" description = "Create and push a tagged commit matching the backend version" doLast { val desc = project.properties["m"]?.toString() ?: throw InvalidUserDataException("Das Argument -Pm=\"Beschreibung dieser Version\" wird benötigt") - exec { commandLine("git", "add", "CHANGELOG.md") } - exec { commandLine("git", "commit", "-m", "release: v$versionFromBackend") } - exec { commandLine("git", "tag", versionFromBackend, "-m", desc) } - exec { commandLine("git", "push", "--follow-tags", "--recurse-submodules=on-demand") } + + providers.exec { commandLine("git", "add", "CHANGELOG.md") } + providers.exec { commandLine("git", "commit", "-m", "release: v$versionFromBackend") } + providers.exec { commandLine("git", "tag", versionFromBackend, "-m", desc) } + providers.exec { commandLine("git", "push", "--follow-tags", "--recurse-submodules=on-demand") } } } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c51cbf1..e69d040 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index bebc179..5a5913a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,16 +1,2 @@ -rootProject.name = "software-challenge-gui" - includeBuild("backend/gradle/custom-tasks") -includeBuild("backend") { - // https://publicobject.com/2021/03/11/includebuild - dependencySubstitution { - substitute(module("software-challenge:plugin2024")) - .with(project(":plugin")) - substitute(module("software-challenge:plugin2025")) - .with(project(":plugin2025")) - substitute(module("software-challenge:plugin")) - .with(project(":plugin2026")) - substitute(module("software-challenge:server")) - .with(project(":server")) - } -} \ No newline at end of file +includeBuild("backend") diff --git a/src/main/kotlin/sc/gui/AppStyle.kt b/src/main/kotlin/sc/gui/AppStyle.kt index 01acab4..612aa8c 100644 --- a/src/main/kotlin/sc/gui/AppStyle.kt +++ b/src/main/kotlin/sc/gui/AppStyle.kt @@ -11,6 +11,7 @@ import javafx.scene.text.FontWeight import javafx.scene.text.TextAlignment import sc.api.plugins.Team import sc.gui.model.AppModel +import sc.plugin2098.util.Connect4Constants import tornadofx.* class AppStyle: Stylesheet() { @@ -22,7 +23,7 @@ class AppStyle: Stylesheet() { const val pieceOpacity = 1.0 - val fontSizeUnscaled = Font.getDefault().also { logger.debug("System Font: $it") }.size.pt + val fontSizeUnscaled = Font.getDefault().also { logger.debug { "System Font: $it" } }.size.pt val fontSizeRegular = fontSizeUnscaled * AppModel.scaling.value val fontSizeSmall = fontSizeRegular * 0.6 val fontSizeBig = fontSizeRegular * 1.5 @@ -157,7 +158,35 @@ class AppStyle: Stylesheet() { prefWidth = 100.percent } - piranhasStyles() + connect4Styles() + } + + fun connect4Styles() { + background { + opacity = 0.7 + backgroundColor += c("#88DAF7") + backgroundImage += resources.url("/piranhas/water_b.png").toURI() + backgroundRepeat += BackgroundRepeat.REPEAT to BackgroundRepeat.REPEAT + } + + (1..3).forEach { size -> + Team.entries.forEach { team -> + ".${team}_${size}" { + image = resources.url("/piranhas/${team.color}_${(96 + size).toChar()}.png") + .toURI() + } + } + } + + ".one-chip" { image = resources.url("/connect4/chip-rot.png").toURI() } + ".two-chip" { image = resources.url("/connect4/chip-gelb.png").toURI() } + ".one-chip-winning" { image = resources.url("/connect4/chip-rot-winning.png").toURI() } + ".two-chip-winning" { image = resources.url("/connect4/chip-gelb-winning.png").toURI() } + ".cell" { image = resources.url("/connect4/cell-debug.png").toURI() } + ".grid" { + backgroundImage += resources.url("/connect4/board.png").toURI() + backgroundSize += BackgroundSize(1.0, 1.0, true, true, false, false) + } } fun piranhasStyles() { diff --git a/src/main/kotlin/sc/gui/controller/ServerController.kt b/src/main/kotlin/sc/gui/controller/ServerController.kt index 0b8c741..f67f09c 100644 --- a/src/main/kotlin/sc/gui/controller/ServerController.kt +++ b/src/main/kotlin/sc/gui/controller/ServerController.kt @@ -1,7 +1,7 @@ package sc.gui.controller import ch.qos.logback.classic.LoggerContext -import ch.qos.logback.core.util.StatusPrinter +import ch.qos.logback.core.util.StatusPrinter2 import org.slf4j.LoggerFactory import sc.server.Configuration import sc.server.Lobby @@ -13,7 +13,7 @@ class ServerController : Controller() { fun startServer() { // output logback diagnostics to see if a logback.xml config was found val lc = LoggerFactory.getILoggerFactory() as LoggerContext - StatusPrinter.print(lc) + StatusPrinter2().print(lc) Configuration.loadServerProperties() Configuration.set(Configuration.SAVE_REPLAY, true) diff --git a/src/main/kotlin/sc/gui/view/AppView.kt b/src/main/kotlin/sc/gui/view/AppView.kt index 2098dda..396a469 100644 --- a/src/main/kotlin/sc/gui/view/AppView.kt +++ b/src/main/kotlin/sc/gui/view/AppView.kt @@ -29,13 +29,13 @@ class AppView: View("Software-Challenge Germany") { // TODO help menus keep disappearing and is offset menu(graphic = sochaIcon) { item("Beenden", "Shortcut+Q").action { - logger.debug("Quitting!") + logger.debug { "Quitting!" } Platform.exit() } item("Neues Spiel", "Shortcut+N") { enableWhen(controller.model.currentView.isNotEqualTo(ViewType.GAME_CREATION)) action { - logger.debug("New Game!") + logger.debug { "New Game!" } if(controller.model.currentView.get() == ViewType.GAME) { confirm( header = "Neues Spiel anfangen", diff --git a/src/main/kotlin/sc/gui/view/ControlView.kt b/src/main/kotlin/sc/gui/view/ControlView.kt index d60c9b8..e05e2c3 100644 --- a/src/main/kotlin/sc/gui/view/ControlView.kt +++ b/src/main/kotlin/sc/gui/view/ControlView.kt @@ -55,7 +55,7 @@ class ControlView: View() { } } val prev = button { - if(logger.isTraceEnabled) + if(logger.isTraceEnabled()) hoverProperty().listenImmediately { logger.trace { "$this: $padding on hover $it" } } @@ -86,7 +86,7 @@ class ControlView: View() { ) } button { - if(logger.isTraceEnabled) + if(logger.isTraceEnabled()) hoverProperty().listenImmediately { logger.trace { "$this: $padding on hover $it" } } diff --git a/src/main/kotlin/sc/gui/view/PieceImage.kt b/src/main/kotlin/sc/gui/view/PieceImage.kt index 3cc18a3..37720f0 100644 --- a/src/main/kotlin/sc/gui/view/PieceImage.kt +++ b/src/main/kotlin/sc/gui/view/PieceImage.kt @@ -84,7 +84,6 @@ class PieceImage(private val sizeProperty: ObservableDoubleValue, val content: S } fun addChild(graphic: String, index: Int? = null) { - //logger.trace { "$this: Adding $graphic" } children.add(index ?: children.size, ResizableImageView(sizeProperty).apply { addClass(graphic) if(graphic == "penguin") diff --git a/src/main/kotlin/sc/gui/view/game/Connect4Board.kt b/src/main/kotlin/sc/gui/view/game/Connect4Board.kt new file mode 100644 index 0000000..dfb1b8f --- /dev/null +++ b/src/main/kotlin/sc/gui/view/game/Connect4Board.kt @@ -0,0 +1,114 @@ +package sc.gui.view.game + +import javafx.geometry.Pos +import javafx.scene.Node +import javafx.scene.effect.Glow +import javafx.scene.input.KeyEvent +import javafx.scene.layout.GridPane +import sc.api.plugins.Coordinates +import sc.gui.view.GameBoard +import sc.gui.view.PieceImage +import sc.plugin2098.FieldState +import sc.plugin2098.GameState +import sc.plugin2098.util.Connect4Constants +import sc.plugin2098.util.GameRuleLogic +import tornadofx.* + +class Connect4Board: GameBoard() { + + private val gridSize + get() = squareSize.div(Connect4Constants.BOARD_WIDTH) // "Length of the smaller side of the window." + + val grid: GridPane = GridPane().addClass("grid") + + override val root = hbox { + this.alignment = Pos.BOTTOM_CENTER + vbox { + this.alignment = Pos.CENTER + add(grid) + } + } + + var selected: Node? = null + val hovers = ArrayList() + + fun addToGrid(child: Node, coordinates: Coordinates) { + grid.add(child, coordinates.x, Connect4Constants.BOARD_HEIGHT - 1 - coordinates.y) + } + + override fun onNewState(oldState: GameState?, state: GameState?) { + selected = grid + logger.debug { "New State: $state" } + grid.children.clear() + hovers.clear() + selected = null + + // this ensures proper sizing of the board + (0 until Connect4Constants.BOARD_WIDTH).forEach { y -> + grid.add(PieceImage(gridSize, "cell").apply { opacity = 0.0 }, y, 0) + } + + (0 until Connect4Constants.BOARD_HEIGHT).forEach { y -> + grid.add(PieceImage(gridSize, "cell").apply { opacity = 0.0 }, 0, y) + } + + state?.let { state -> + + var winningCoords: List = ArrayList() + + state.isOver?.let { isOver -> + if(isOver && state.lastMove != null) { + winningCoords = GameRuleLogic.get4Connected(state.board, state.otherTeam, state.lastMove!!.position) + println(winningCoords.size) + } + } + + state.board.forEach { (pos: Coordinates, field: FieldState) -> + if(field.team == null) { + return@forEach + } + val piece: PieceImage + + if(winningCoords.contains(pos)) { + piece = PieceImage(gridSize, field.team.let { team -> "${team}-chip-winning".lowercase() }) + piece.effect = Glow(1.0) + } else { + piece = PieceImage(gridSize, field.team.let { team -> "${team}-chip".lowercase() }) + } + + addToGrid(piece, pos) + } + } + } + + override fun handleKeyPress(state: GameState, keyEvent: KeyEvent): Boolean { + + var x = keyEvent.text.toIntOrNull() ?: return false + x -= 1 + + state.getSensibleMoves().forEach { move -> + if(move.position.x == x) { + sendHumanMove(move) + return true + } + } + + return false + } + + override fun renderHumanControls(state: GameState) { + state.getSensibleMoves().forEach { move -> + + val piece = PieceImage(gridSize, "${state.currentTeam}-chip".lowercase()) + + piece.opacity = 0.5 + //piece.effect = Glow(1.0) + + piece.onLeftClick { + sendHumanMove(move) + } + + addToGrid(piece, move.position) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/sc/gui/view/game/HuIBoard.kt b/src/main/kotlin/sc/gui/view/game/HuIBoard.kt index ba21f91..e1a5224 100644 --- a/src/main/kotlin/sc/gui/view/game/HuIBoard.kt +++ b/src/main/kotlin/sc/gui/view/game/HuIBoard.kt @@ -286,7 +286,7 @@ class HuIBoard: GameBoard() { putOnPosition( Button(carrotCostString(car.amount)).apply { fixHoverInsets() - if(logger.isTraceEnabled) + if(logger.isTraceEnabled()) hoverProperty().listenImmediately { logger.trace { "$this: $padding on hover $it" } } diff --git a/src/main/kotlin/sc/gui/view/game/PenguinsBoard.kt b/src/main/kotlin/sc/gui/view/game/PenguinsBoard.kt index 50571cd..ef117e8 100644 --- a/src/main/kotlin/sc/gui/view/game/PenguinsBoard.kt +++ b/src/main/kotlin/sc/gui/view/game/PenguinsBoard.kt @@ -53,7 +53,7 @@ class PenguinBoard: View() { size.bind(Bindings.min(widthProperty(), heightProperty().multiply(1.6))) anchorpane { this.paddingAll = AppStyle.spacing - val stateListener = ChangeListener { _, oldState, state -> + val stateListener = ChangeListener { _, oldState: GameState?, state -> clearTargetHighlights() if(state == null) { //children.remove(BOARD_SIZE.toDouble().pow(2).toInt(), children.size) diff --git a/src/main/resources/META-INF/services/sc.gui.view.GameBoard b/src/main/resources/META-INF/services/sc.gui.view.GameBoard index aaec2a4..bc28cf4 100644 --- a/src/main/resources/META-INF/services/sc.gui.view.GameBoard +++ b/src/main/resources/META-INF/services/sc.gui.view.GameBoard @@ -1 +1 @@ -sc.gui.view.game.PiranhasBoard +sc.gui.view.game.Connect4Board diff --git a/src/main/resources/connect4/board.png b/src/main/resources/connect4/board.png new file mode 100644 index 0000000..58247b5 Binary files /dev/null and b/src/main/resources/connect4/board.png differ diff --git a/src/main/resources/connect4/cell-debug.png b/src/main/resources/connect4/cell-debug.png new file mode 100644 index 0000000..5415b4e Binary files /dev/null and b/src/main/resources/connect4/cell-debug.png differ diff --git a/src/main/resources/connect4/cell.png b/src/main/resources/connect4/cell.png new file mode 100644 index 0000000..e374f6d Binary files /dev/null and b/src/main/resources/connect4/cell.png differ diff --git a/src/main/resources/connect4/chip-gelb-winning.png b/src/main/resources/connect4/chip-gelb-winning.png new file mode 100644 index 0000000..3af3c44 Binary files /dev/null and b/src/main/resources/connect4/chip-gelb-winning.png differ diff --git a/src/main/resources/connect4/chip-gelb.png b/src/main/resources/connect4/chip-gelb.png new file mode 100644 index 0000000..59421bc Binary files /dev/null and b/src/main/resources/connect4/chip-gelb.png differ diff --git a/src/main/resources/connect4/chip-rot-winning.png b/src/main/resources/connect4/chip-rot-winning.png new file mode 100644 index 0000000..1355777 Binary files /dev/null and b/src/main/resources/connect4/chip-rot-winning.png differ diff --git a/src/main/resources/connect4/chip-rot.png b/src/main/resources/connect4/chip-rot.png new file mode 100644 index 0000000..d1d59fa Binary files /dev/null and b/src/main/resources/connect4/chip-rot.png differ