diff --git a/README.md b/README.md index 0d696dd..6199738 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ The animated GIF is highly inspired by the classic game Hayauchi Super Igo for N ```shell Usage: sgf2gif options_list -Options: - --file, -f -> The SGF-file to convert to a GIF (always required) - --output, -o -> The destination file to write the GIF to. (always required) - --theme [NES] -> The theme to render the board with { Value should be one of [nes] } - --loop, -l [false] -> Whether the animation should be looped or not +Options: + --file, -f -> The SGF-file to convert to a GIF (always required) + --output, -o -> The destination file to write the GIF to. (Optional) + --theme [NES] -> The theme to render the board with { Value should be one of [nes, classic] } + --loop, -l [false] -> Whether the animation should be looped or not --width, -w [1000] -> The width of the image. { Int } --height, -h [1000] -> The height of the image. { Int } --move-number, -mn [2147483647] -> The move number up to which the animation will run to. { Int } @@ -22,3 +22,9 @@ Options: java -jar sgf2gif.jar -f ~/game.sgf -o ~/game.gif --theme nes ``` ![](https://github.com/Ekenstein/sgf2gif/blob/main/nes.gif?raw=true) + +### Classic theme +```shell +java -jar sgf2gif.jar -f ~/game.sgf -o ~/game.gif --theme classic +``` +![](https://github.com/Ekenstein/sgf2gif/blob/main/classic.gif?raw=true) \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ce7f9b6..9148b19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,14 +12,14 @@ application { plugins { application - kotlin("jvm") version "1.7.10" - id("org.jlleitschuh.gradle.ktlint") version "10.3.0" - id("com.github.ben-manes.versions") version "0.42.0" - id("com.github.johnrengelman.shadow") version "7.1.2" + kotlin("jvm") version "1.8.21" + id("org.jlleitschuh.gradle.ktlint") version "11.3.2" + id("com.github.ben-manes.versions") version "0.46.0" + id("com.github.johnrengelman.shadow") version "8.1.1" } group = "com.github.ekenstein" -version = "0.3.2" +version = "0.4.0" repositories { mavenCentral() @@ -29,8 +29,8 @@ repositories { } dependencies { - implementation("com.github.Ekenstein", "haengma", "2.2.4") - implementation("org.jetbrains.kotlinx", "kotlinx-cli", "0.3.4") + implementation("com.github.Ekenstein", "haengma", "2.2.6") + implementation("org.jetbrains.kotlinx", "kotlinx-cli", "0.3.5") testImplementation(kotlin("test")) } @@ -79,6 +79,10 @@ tasks { dependsOn(dependencyUpdateSentinel) } + withType { + targetCompatibility = "1.8" + } + withType { kotlinOptions.jvmTarget = "1.8" } @@ -89,14 +93,17 @@ ktlint { } class UpgradeToUnstableFilter : ComponentFilter { - override fun reject(cs: ComponentSelectionWithCurrent) = reject(cs.currentVersion, cs.candidate.version) + override fun reject(candidate: ComponentSelectionWithCurrent) = reject( + candidate.currentVersion, + candidate.candidate.version + ) private fun reject(old: String, new: String): Boolean { return !isStable(new) && isStable(old) // no unstable proposals for stable dependencies } private fun isStable(version: String): Boolean { - val stableKeyword = setOf("RELEASE", "FINAL", "GA").any { version.toUpperCase().contains(it) } + val stableKeyword = setOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } val stablePattern = version.matches(Regex("""^[0-9,.v-]+(-r)?$""")) return stableKeyword || stablePattern } @@ -104,11 +111,20 @@ class UpgradeToUnstableFilter : ComponentFilter { class IgnoredDependencyFilter : ComponentFilter { private val ignoredDependencies = mapOf( - "ktlint" to listOf("0.46.0", "0.46.1") // doesn't currently work. + "ktlint" to listOf( + "0.46.0", + "0.46.1", + "0.47.0", + "0.47.1", + "0.48.0", + "0.48.1", + "0.48.2", + "0.49.0" + ) // doesn't currently work. ) - override fun reject(p0: ComponentSelectionWithCurrent): Boolean { - return ignoredDependencies[p0.candidate.module].orEmpty().contains(p0.candidate.version) + override fun reject(candidate: ComponentSelectionWithCurrent): Boolean { + return ignoredDependencies[candidate.candidate.module].orEmpty().contains(candidate.candidate.version) } } diff --git a/classic.gif b/classic.gif new file mode 100644 index 0000000..b333fb6 Binary files /dev/null and b/classic.gif differ diff --git a/dist/lib/sgf2gif.jar b/dist/lib/sgf2gif.jar index 641d6f8..b77a9e6 100644 Binary files a/dist/lib/sgf2gif.jar and b/dist/lib/sgf2gif.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 85847ef..8f5ef1a 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-7.5-rc-3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists \ No newline at end of file diff --git a/src/main/kotlin/com/github/ekenstein/sgf2gif/BoardRenderer.kt b/src/main/kotlin/com/github/ekenstein/sgf2gif/BoardTheme.kt similarity index 97% rename from src/main/kotlin/com/github/ekenstein/sgf2gif/BoardRenderer.kt rename to src/main/kotlin/com/github/ekenstein/sgf2gif/BoardTheme.kt index 8325346..f1af296 100644 --- a/src/main/kotlin/com/github/ekenstein/sgf2gif/BoardRenderer.kt +++ b/src/main/kotlin/com/github/ekenstein/sgf2gif/BoardTheme.kt @@ -15,13 +15,13 @@ import kotlin.time.Duration data class Stone(val point: SgfPoint, val color: SgfColor) -interface BoardRenderer { +interface BoardTheme { fun drawEmptyBoard(g: Graphics2D) fun drawStone(g: Graphics2D, stone: Stone) fun clearPoint(g: Graphics2D, x: Int, y: Int) } -fun BoardRenderer.render( +fun BoardTheme.render( outputStream: ImageOutputStream, editor: SgfEditor, width: Int, diff --git a/src/main/kotlin/com/github/ekenstein/sgf2gif/Main.kt b/src/main/kotlin/com/github/ekenstein/sgf2gif/Main.kt index 42a53ab..188544f 100644 --- a/src/main/kotlin/com/github/ekenstein/sgf2gif/Main.kt +++ b/src/main/kotlin/com/github/ekenstein/sgf2gif/Main.kt @@ -1,14 +1,13 @@ package com.github.ekenstein.sgf2gif -import com.github.ekenstein.sgf.editor.SgfEditor -import com.github.ekenstein.sgf.editor.goToLastNode -import com.github.ekenstein.sgf.editor.goToNextMove -import com.github.ekenstein.sgf.editor.stay -import com.github.ekenstein.sgf.editor.tryRepeat +import com.github.ekenstein.sgf.editor.* import com.github.ekenstein.sgf.utils.get import com.github.ekenstein.sgf.utils.orElse +import com.github.ekenstein.sgf2gif.themes.Classic import com.github.ekenstein.sgf2gif.themes.Nes import kotlinx.cli.ArgParser +import java.io.File +import java.nio.file.Paths import java.text.NumberFormat import javax.imageio.stream.FileImageOutputStream import kotlin.time.Duration.Companion.seconds @@ -21,16 +20,21 @@ fun main(args: Array) { val options = Options(parser) parser.parse(args) - val editor = SgfEditor(options.sgf) + val (inputFile, sgf) = options.sgf + + val editor = SgfEditor(sgf) .tryRepeat(options.moveNumber) { it.goToNextMove() } .orElse { it.goToLastNode().stay() } .get() val (boardWidth, boardHeight) = editor.boardSize() - val outputFile = options.output.toFile() + val outputFile = options.output?.toFile() + ?: createOutputFile(inputFile.nameWithoutExtension) + FileImageOutputStream(outputFile).use { outputStream -> val renderer = when (options.theme) { Theme.NES -> Nes(options.width, options.height, boardWidth, boardHeight) + Theme.Classic -> Classic(options.width, options.height, boardWidth, boardHeight) } renderer.render(outputStream, editor, options.width, options.height, options.delay.seconds, options.loop) { @@ -38,5 +42,13 @@ fun main(args: Array) { } } - println("\nExported the SGF to ${options.output}") + println("\nExported the SGF to ${outputFile.absolutePath}") +} + +private fun createOutputFile(fileName: String): File { + val currentWorkingDirectory = System.getProperty("user.dir") + val fileNameWithGifExtension = "$fileName.gif" + val filePath = Paths.get(currentWorkingDirectory, fileNameWithGifExtension) + + return filePath.toFile() } diff --git a/src/main/kotlin/com/github/ekenstein/sgf2gif/Options.kt b/src/main/kotlin/com/github/ekenstein/sgf2gif/Options.kt index 4934cc9..2b57911 100644 --- a/src/main/kotlin/com/github/ekenstein/sgf2gif/Options.kt +++ b/src/main/kotlin/com/github/ekenstein/sgf2gif/Options.kt @@ -9,6 +9,7 @@ import kotlinx.cli.ArgType import kotlinx.cli.ParsingException import kotlinx.cli.default import kotlinx.cli.required +import java.io.File import java.nio.file.InvalidPathException import java.nio.file.Path import kotlin.io.path.exists @@ -23,10 +24,10 @@ class Options(parser: ArgParser) { val output by parser.option( type = PathArgType(), - description = "The destination file to write the GIF to.", + description = "The destination file to write the GIF to. (Optional)", shortName = "o", fullName = "output" - ).required() + ) val theme by parser.option( type = ArgType.Choice(), @@ -83,25 +84,26 @@ private class PathArgType : ArgType(true) { } } -private class SgfArgType : ArgType(true) { +private class SgfArgType : ArgType>(true) { override val description: kotlin.String get() = "" - override fun convert(value: kotlin.String, name: kotlin.String): SgfGameTree { - val path = try { - Path.of(value) - } catch (ex: InvalidPathException) { - throw ParsingException("Option $name is expected to be a path. $value is provided.") - } + override fun convert(value: kotlin.String, name: kotlin.String): Pair { + val file = File(value) - if (!path.exists()) { - throw ParsingException("Option $name is expected to exist. The path was $path") + if (!file.exists()) { + throw ParsingException("Option $name is expected to exist. The path was $value") } return try { - SgfCollection.from(path) { - ignoreMalformedProperties = true - }.trees.head + val collection = file.inputStream().use { inputStream -> + SgfCollection.from(inputStream) { + ignoreMalformedProperties = true + } + } + + val tree = collection.trees.head + file to tree } catch (ex: SgfException.ParseError) { throw ParsingException("Option $name is expected to be a valid SGF file.") } diff --git a/src/main/kotlin/com/github/ekenstein/sgf2gif/Theme.kt b/src/main/kotlin/com/github/ekenstein/sgf2gif/Theme.kt index 8e5c739..b4cac1c 100644 --- a/src/main/kotlin/com/github/ekenstein/sgf2gif/Theme.kt +++ b/src/main/kotlin/com/github/ekenstein/sgf2gif/Theme.kt @@ -1,5 +1,6 @@ package com.github.ekenstein.sgf2gif enum class Theme { - NES + NES, + Classic, } diff --git a/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Classic.kt b/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Classic.kt new file mode 100644 index 0000000..21ccc49 --- /dev/null +++ b/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Classic.kt @@ -0,0 +1,147 @@ +package com.github.ekenstein.sgf2gif.themes + +import com.github.ekenstein.sgf.SgfColor +import com.github.ekenstein.sgf.SgfPoint +import com.github.ekenstein.sgf2gif.BoardTheme +import com.github.ekenstein.sgf2gif.Stone +import com.github.ekenstein.sgf2gif.starPoints +import java.awt.Color +import java.awt.Graphics2D +import kotlin.math.max +import kotlin.math.min + +private const val BOARD_SCALE = 0.95 + +class Classic( + private val canvasWidth: Int, + private val canvasHeight: Int, + private val boardWidth: Int, + private val boardHeight: Int +) : BoardTheme { + private val boardColor = Color.WHITE + + override fun drawEmptyBoard(g: Graphics2D) { + g.color = boardColor + g.fillRect(0, 0, canvasWidth, canvasHeight) + + drawIntersections(g) + + if (boardWidth != boardHeight) { + return + } + + starPoints(boardWidth).forEach { starPoint -> + drawStarPoint(g, starPoint) + } + } + + override fun drawStone(g: Graphics2D, stone: Stone) { + val middleX = boardX(stone.point.x - 1, canvasWidth, boardWidth) + val middleY = boardY(stone.point.y - 1, canvasHeight, boardHeight) + + val circleWidth = (intersectionWidth(canvasWidth, boardWidth) * 0.90).toInt() + val circleHeight = (intersectionHeight(canvasHeight, boardHeight) * 0.90).toInt() + + val topLeftX = middleX - (circleWidth / 2) + val topLeftY = middleY - (circleHeight / 2) + + when (stone.color) { + SgfColor.Black -> { + g.color = Color.BLACK + g.fillOval(topLeftX, topLeftY, circleWidth, circleHeight) + } + SgfColor.White -> { + g.color = Color.WHITE + g.fillOval(topLeftX, topLeftY, circleWidth, circleHeight) + + g.color = Color.BLACK + g.drawOval(topLeftX, topLeftY, circleWidth, circleHeight) + } + } + } + + override fun clearPoint(g: Graphics2D, x: Int, y: Int) { + val rectangleWidth = intersectionWidth(canvasWidth, boardWidth) + val rectangleHeight = intersectionHeight(canvasHeight, boardHeight) + val middleX = boardX(x - 1, canvasWidth, boardWidth) + val middleY = boardY(y - 1, canvasHeight, boardHeight) + val topLeftX = middleX - (rectangleWidth / 2) + val topLeftY = middleY - (rectangleHeight / 2) + + g.color = boardColor + g.fillRect(topLeftX, topLeftY, rectangleWidth, rectangleHeight) + + val starPoints = if (boardWidth == boardHeight) { + starPoints(boardWidth) + } else { + emptySet() + } + + val point = SgfPoint(x, y) + if (point in starPoints) { + drawStarPoint(g, point) + } + + g.color = Color.BLACK + g.drawLine( + max(xOffset(canvasWidth), middleX - (rectangleWidth / 2)), + middleY, + min(boardHeight(canvasHeight), middleX + (rectangleWidth / 2)), + middleY + ) + + g.drawLine( + middleX, + max(yOffset(canvasHeight), middleY - (rectangleHeight / 2)), + middleX, + min(boardWidth(canvasWidth), middleY + (rectangleHeight / 2)) + ) + } + + private fun drawStarPoint(g: Graphics2D, point: SgfPoint) { + val middleX = boardX(point.x - 1, canvasWidth, boardWidth) + val middleY = boardY(point.y - 1, canvasHeight, boardHeight) + + val circleWidth = (intersectionWidth(canvasWidth, boardWidth) * 0.20).toInt() + val circleHeight = (intersectionHeight(canvasHeight, boardHeight) * 0.20).toInt() + + val topLeftX = middleX - (circleWidth / 2) + val topLeftY = middleY - (circleHeight / 2) + + g.color = Color.BLACK + g.fillOval(topLeftX, topLeftY, circleWidth, circleHeight) + } + + private fun drawIntersections(g: Graphics2D) { + val yOffset = yOffset(canvasHeight) + val xOffset = xOffset(canvasWidth) + + val intersectionHeight = intersectionHeight(canvasHeight, boardHeight) + val intersectionWidth = intersectionWidth(canvasWidth, boardWidth) + + g.color = Color.BLACK + + repeat(boardWidth) { x -> + val gx = boardX(x, canvasWidth, boardWidth) + g.drawLine(gx, yOffset, gx, yOffset + (intersectionHeight * (boardHeight - 1))) + } + + repeat(boardHeight) { y -> + val gy = boardY(y, canvasHeight, boardHeight) + g.drawLine(xOffset, gy, xOffset + (intersectionWidth * (boardWidth - 1)), gy) + } + } +} + +private fun intersectionWidth(canvasWidth: Int, boardWidth: Int) = boardWidth(canvasWidth) / boardWidth +private fun intersectionHeight(canvasHeight: Int, boardHeight: Int) = boardHeight(canvasHeight) / boardHeight +private fun boardWidth(canvasWidth: Int) = (BOARD_SCALE * canvasWidth).toInt() +private fun boardHeight(canvasHeight: Int) = (BOARD_SCALE * canvasHeight).toInt() +private fun boardX(x: Int, canvasWidth: Int, boardWidth: Int) = + (intersectionWidth(canvasWidth, boardWidth) * x) + xOffset(canvasWidth) + +private fun boardY(y: Int, canvasHeight: Int, boardHeight: Int) = + (intersectionHeight(canvasHeight, boardHeight) * y) + yOffset(canvasHeight) + +private fun xOffset(canvasWidth: Int) = canvasWidth - boardWidth(canvasWidth) +private fun yOffset(canvasHeight: Int) = canvasHeight - boardHeight(canvasHeight) diff --git a/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Nes.kt b/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Nes.kt index bba7d16..d463beb 100644 --- a/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Nes.kt +++ b/src/main/kotlin/com/github/ekenstein/sgf2gif/themes/Nes.kt @@ -2,7 +2,7 @@ package com.github.ekenstein.sgf2gif.themes import com.github.ekenstein.sgf.SgfColor import com.github.ekenstein.sgf.SgfPoint -import com.github.ekenstein.sgf2gif.BoardRenderer +import com.github.ekenstein.sgf2gif.BoardTheme import com.github.ekenstein.sgf2gif.Stone import com.github.ekenstein.sgf2gif.starPoints import java.awt.BasicStroke @@ -32,7 +32,7 @@ class Nes( private val height: Int, private val boardWidth: Int, private val boardHeight: Int -) : BoardRenderer { +) : BoardTheme { private val gobanWidth = BOARD_SCALE * width private val gobanHeight = BOARD_SCALE * height private val gobanThickness = gobanHeight * BOARD_THICKNESS_FACTOR