diff --git a/.gitignore b/.gitignore index 33bfc3b..3a51581 100644 --- a/.gitignore +++ b/.gitignore @@ -19,52 +19,18 @@ gradle-app.setting .idea -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - ### Kotlin template # Compiled class file *.class @@ -89,192 +55,3 @@ fabric.properties # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* - -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### Kotlin template -# Compiled class file -*.class - -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -### Gradle template -.gradle -**/build/ -!client/src/**/build/ - -# Ignore Gradle GUI config -gradle-app.setting - -# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) -!gradle-wrapper.jar - -# Cache of project -.gradletasknamecache - -# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties - -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - diff --git a/build.gradle.kts b/build.gradle.kts index d571af3..3e1c1df 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,32 +1,9 @@ -plugins { - application - kotlin("jvm") version "1.4.32" - kotlin("plugin.serialization") version "1.4.32" - id("io.gitlab.arturbosch.detekt") version "1.16.0-RC2" +allprojects { + repositories { + jcenter() + } } -group = "ru.ifmo.jb" -version = "1.0-SNAPSHOT" -val ktor_version = "1.5.3" - -application { - mainClass.set("ru.ifmo.sd.MainKt") -} - -repositories { - mavenCentral() -} - -dependencies { - implementation("io.ktor:ktor-websockets:$ktor_version") - implementation("io.ktor:ktor-client-cio:$ktor_version") - implementation("io.ktor:ktor-client-gson:$ktor_version") - implementation("io.ktor:ktor-gson:$ktor_version") - implementation("io.ktor:ktor-server-core:$ktor_version") - implementation("io.ktor:ktor-server-netty:$ktor_version") - testImplementation("io.ktor:ktor-server-tests:$ktor_version") -} - -tasks.test { - useJUnit() +subprojects { + version = "1.0" } diff --git a/game-client/build.gradle.kts b/game-client/build.gradle.kts new file mode 100644 index 0000000..34c6d13 --- /dev/null +++ b/game-client/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + java + application + kotlin("jvm") version "1.4.21" +} + +group = "ru.ifmo.jb" +version = "1.0-SNAPSHOT" +val ktor_version = "1.5.2" + +repositories { + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib")) + implementation("org.jetbrains.kotlin:kotlin-reflect:1.4.32") + implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("io.ktor:ktor-client-gson:$ktor_version") + implementation("io.ktor:ktor-client-jackson:$ktor_version") + implementation("io.ktor:ktor-client-serialization:1.5.2") + implementation(project(":game-server")) +} + +application { + mainClass.set("ru.ifmo.sd.MainKt") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/game-client/src/main/kotlin/ru/ifmo/sd/Main.kt b/game-client/src/main/kotlin/ru/ifmo/sd/Main.kt new file mode 100644 index 0000000..55f7a02 --- /dev/null +++ b/game-client/src/main/kotlin/ru/ifmo/sd/Main.kt @@ -0,0 +1,74 @@ +package ru.ifmo.sd + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.* +import ru.ifmo.sd.httpapi.models.* +import ru.ifmo.sd.stuff.GUI +import ru.ifmo.sd.stuff.ServerAPI +import java.awt.EventQueue +import javax.swing.JFrame +import javax.swing.JOptionPane + +private val inputNameFrame = JFrame() + +fun main() { + ServerAPI.client = HttpClient(CIO) { + install(JsonFeature) { + serializer = KotlinxSerializer() + } + } +// ServerAPI.initLocalServer() + val response = inputName() + println(ServerAPI.nickname) + if (ServerAPI.nickname == null) { + ServerAPI.client!!.close() + inputNameFrame.dispose() + } else { + println(response) + EventQueue.invokeLater { createAndShowGUI(response!!) } + } +} + +private fun createAndShowGUI(config: JoinGameInfo) { + + val frame = GUI("Roguelike", config) + frame.isVisible = true +} + +private fun inputName(): JoinGameInfo? { + inputNameFrame.isVisible = true + inputNameFrame.defaultCloseOperation = JFrame.EXIT_ON_CLOSE + inputNameFrame.setLocationRelativeTo(null) + + var isNameTaken = false + var isNameValid = true + var response: JoinGameInfo? = null + while (true) { + val message = "Type your nickname" + + if (!isNameValid) " (name should contain latin letters or digits)" + else if (isNameTaken) " (choose another one)" + else "" + ServerAPI.nickname = JOptionPane.showInputDialog(inputNameFrame, message) + + isNameValid = validateNickname(ServerAPI.nickname) + if (!isNameValid) continue + + var success = true + try { + response = ServerAPI.joinMultiplayer() + } catch (e: Exception) { + println(e.localizedMessage) + success = false + isNameTaken = true + } + if (success) break + } + inputNameFrame.isVisible = false + return response +} + +private fun validateNickname(name: String?): Boolean { + return name != null && name.length < 20 && name.matches("[a-zA-Z0-9]+".toRegex()) +} \ No newline at end of file diff --git a/game-client/src/main/kotlin/ru/ifmo/sd/stuff/GUI.kt b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/GUI.kt new file mode 100644 index 0000000..d7acffc --- /dev/null +++ b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/GUI.kt @@ -0,0 +1,400 @@ +package ru.ifmo.sd.stuff + +import ru.ifmo.sd.httpapi.models.* +import ru.ifmo.sd.stuff.ColoredSymbol.* +import java.awt.BorderLayout +import java.awt.Color.* +import java.awt.Container +import java.awt.event.KeyEvent +import java.awt.event.KeyEvent.* +import java.awt.event.KeyListener +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.swing.* +import javax.swing.text.Style + +enum class MoveEvent { + UP, DOWN, LEFT, RIGHT, NONE +} + +class GUI(title: String, gameConfiguration: JoinGameInfo) : JFrame(), KeyListener { + private var prevPos: Position? = null // TODO: костыль + private var currPos = gameConfiguration.playerPos + set(value) { + prevPos = currPos + field = value + } + private var isDead = false + private val mainPanel = JPanel() + private val headerPanel = JPanel() + private val mapPanel = JPanel() + private val infoPanel = JPanel() + private val infoLabel = JLabel("") + private var map = SymbolMap(gameConfiguration) + private var mapTextPane = MapTextPane() + private var isMultiplayer = true + private var currPlayer: String? = null + private var prevGameState: GameState? = null + + init { + createUI(title) + startRepaintThread() + } + + private fun createUI(title: String) { + setTitle(title) + + createLayout() + createMenuBar() + makeNotFocusable() + + defaultCloseOperation = EXIT_ON_CLOSE + addWindowListener(object : WindowAdapter() { + override fun windowClosing(e: WindowEvent) { + try { + disconnect(currPos) + } catch (e: Exception) { + println(e.localizedMessage) + } + ServerAPI.client!!.close() + ServerAPI.killLocalServer() + e.window.dispose() + } + }) + setSize(800, 600) + isResizable = false + setLocationRelativeTo(null) + + // TODO should work regardless of component being focused + isFocusable = true + focusTraversalKeysEnabled = false + this.addKeyListener(this) + + requestFocus() + requestFocusInWindow() + } + + private fun startRepaintThread() { + Thread { + while (true) { + try { + Thread.sleep(30) + } catch (e: InterruptedException) { + break + } + if (!isYourTurn()) { + val gameState = getGameState() + if (prevGameState != gameState) { + println("New state $gameState") + if (!gameState.isAtLeastOneNpcAlive) { + ServerAPI.increaseMapSize() + remakeMap() + } else { + prevGameState = gameState + currPlayer = gameState.currentMovePlayerName + isDead = !gameState.isAlive + if (isYourTurn()) { + infoLabel.text = "Your turn!" + } else { + infoLabel.text = "$currPlayer is making a move" + } + map = SymbolMap(gameState) + reloadMapTextPane() + } + } + } + + mainPanel.repaint() + } + }.start() + } + + private fun createLayout() { + add(mainPanel) + mainPanel.layout = BorderLayout() + + headerPanel.setSize(800, 50) + mainPanel.add(headerPanel, BorderLayout.NORTH) + headerPanel.add(infoLabel) + + + mapPanel.setSize(550, 550) + mainPanel.add(mapPanel, BorderLayout.WEST) + mapPanel.add(mapTextPane) + reloadMapTextPane() + + infoPanel.setSize(250, 550) + mainPanel.add(infoPanel, BorderLayout.EAST) + +// val hpLabel = JLabel("HP: ") +// infoPanel.add(hpLabel, BorderLayout.WEST) + } + + private fun createMenuBar() { + val menubar = JMenuBar() + val menu = JMenu("Menu") +// file.mnemonic = KeyEvent.VK_ESCAPE + + val multiplayerItem = if (isMultiplayer) { + val item = JMenuItem("Exit multiplayer") + item.addActionListener { + proceedChangingGameMod() + } + item + } else { + val item = JMenuItem("Join multiplayer") + item.addActionListener { + var shouldChooseAnotherNickname = false + while (true) { + val message = "Type your nickname" + if (shouldChooseAnotherNickname) " (choose another one)" else "" + ServerAPI.nickname = JOptionPane.showInputDialog(this, message) + var success = true + try { + join() + } catch (e: Exception) { + success = false + shouldChooseAnotherNickname = true + } + if (success) break + } + proceedChangingGameMod() + } + item + } + +// val restartItem = JMenuItem("Restart") +//// eMenuItem.mnemonic = KeyEvent.VK_R +// restartItem.toolTipText = "Restart game" +// restartItem.addActionListener { +// isDead = false +// infoLabel.text = "" +// remakeMap(restart = true) +// } + + menu.add(multiplayerItem) +// menu.add(restartItem) + menubar.add(menu) + + jMenuBar = menubar + } + + private fun makeNotFocusable(container: Container = this) { + container.isFocusable = false + for (c in container.components) { + if (c is Container) makeNotFocusable(c) + } + } + + private fun reloadMapTextPane() { + mapTextPane.document.remove(0, mapTextPane.document.length) + for (i in 0 until map.rowSize) { + if (i != 0) { + insertText("\n", mapTextPane.colorMap[BLACK]!!) + } + for (j in 0 until map.columnSize) { + val symbol = map.rows[i][j] + val style = mapTextPane.colorMap[symbol.color]!! + insertText(symbol.char.toString(), style) + } + } + // TODO: make lineSpacing = 0 +// mapTextPane.margin = Insets(0, 0, 0, 0) +// mapTextPane.selectAll() +// val aSet = SimpleAttributeSet(mapTextPane.paragraphAttributes) +// val doc = mapTextPane.styledDocument +// mapTextPane.setParagraphAttributes(aSet, false) +// doc.setParagraphAttributes(0, doc.length, aSet, false) +// StyleConstants.setLineSpacing(aSet, 0F) +// mapTextPane.select(0, 0) + } + + private fun insertText(string: String, style: Style) { + try { + val doc = mapTextPane.document + doc.insertString(doc.length, string, style) + } catch (e: Exception) { + e.printStackTrace() // TODO + } + } + + ///////////////////////////////////// + + var moved = MoveEvent.NONE + var didMove = false + + override fun keyReleased(e: KeyEvent?) {} + override fun keyTyped(e: KeyEvent?) {} + override fun keyPressed(e: KeyEvent?) { + if (isDead) return + if (e != null) { + println("Some key pressed, current=$moved, keyCode=${e.keyCode}, didMove=$didMove, currPos=$currPos") + } else { + println("e == null") + } + if (!didMove && isYourTurn()) { + var newPos = currPos + when (e?.keyCode) { + null -> { + moved = MoveEvent.NONE + } + VK_UP, VK_W -> { + newPos = Position(currPos.row - 1, currPos.column) + if (checkMoveIsValid(currPos, newPos)) { + didMove = true + moved = MoveEvent.UP + currPos = newPos + } + } + VK_DOWN, VK_S -> { + newPos = Position(currPos.row + 1, currPos.column) + if (checkMoveIsValid(currPos, newPos)) { + didMove = true + moved = MoveEvent.DOWN + currPos = newPos + } + } + VK_LEFT, VK_A -> { + newPos = Position(currPos.row, currPos.column - 1) + if (checkMoveIsValid(currPos, newPos)) { + didMove = true + moved = MoveEvent.LEFT + currPos = newPos + } + } + VK_RIGHT, VK_D -> { + newPos = Position(currPos.row, currPos.column + 1) + if (checkMoveIsValid(currPos, newPos)) { + didMove = true + moved = MoveEvent.RIGHT + currPos = newPos + } + } + } + + if (didMove) { + println("Should move=$moved") + + didMove = false + this.validate() + this.mapPanel.validate() + this.mapTextPane.validate() + this.repaint() + this.mapPanel.repaint() + this.mapTextPane.repaint() + +// if ((currPos.row == map.rowSize - 1 && currPos.column == map.columnSize - 2) || +// (currPos.row == 0 && currPos.column == 1)) { +// // stepped away from the level +// remakeMap() +// } else { + val gameMove = move(prevPos!!, currPos) + currPlayer = null + println("gameMove=$gameMove") +// if (gameMove.playerPosition == Position(-1, -1)) { +// // player is dead +// isDead = true +// infoLabel.text = "You are dead!" +// replacePaneSymbol(prevPos!!, NONE) +// } else { + // player moved + replaceSymbol(prevPos!!, gameMove.playerPosition) + map.applyDiff(gameMove.events) + if (map.enemyAmount == 0) { + ServerAPI.increaseMapSize() +// remakeMap() + } else { + // reload pane + val prevPosSaved = prevPos + currPos = gameMove.playerPosition + prevPos = prevPosSaved + reloadMapTextPane() + } +// } +// } + } + } + } + + private fun remakeMap(restart: Boolean = false) { + disconnect(currPos) +// if (restart) { +// ServerAPI.resetMapSize() +// } +// start() // may break previous player's progress + val newConfig = join() + println("newConfig") + println(newConfig.toString()) + map = SymbolMap(newConfig) + isDead = false +// val prevPosSaved = prevPos + currPos = newConfig.playerPos +// prevPos = prevPosSaved + reloadMapTextPane() + } + + // TODO + private fun proceedChangingGameMod() { + disconnect(currPos) + isMultiplayer = !isMultiplayer + val newConfig = join() + map = SymbolMap(newConfig) +// val prevPosSaved = prevPos + currPos = newConfig.playerPos +// prevPos = prevPosSaved + reloadMapTextPane() + } + + private fun checkMoveIsValid(oldPos: Position, newPos: Position): Boolean { + return newPos.row < map.rowSize && newPos.row >= 0 && + newPos.column < map.columnSize && newPos.column >= 0 && + map.rows[newPos.row][newPos.column] != WALL && map.rows[newPos.row][newPos.column] != PLAYER + } + + private fun replaceSymbol(oldPos: Position, newPos: Position, replaceSymb: ColoredSymbol = NONE): Boolean { + if (checkMoveIsValid(oldPos, newPos)) { + val oldSymb = map.rows[oldPos.row][oldPos.column] + map.rows[oldPos.row][oldPos.column] = replaceSymb + map.rows[newPos.row][newPos.column] = oldSymb + replacePaneSymbol(oldPos, replaceSymb) + replacePaneSymbol(newPos, oldSymb) + return true + } + return false + } + + private fun replacePaneSymbol(pos: Position, symbol: ColoredSymbol) { + mapTextPane.isEditable = true + val index = pos.row * (map.columnSize + 1) + pos.column + mapTextPane.select(index, index + 1) +// println("selected text='${mapTextPane.selectedText}'") + mapTextPane.selectionColor = symbol.color + mapTextPane.replaceSelection(symbol.char.toString()) + mapTextPane.isEditable = false + } + + /////////////////// ServerAPI helpers + + private fun join(): JoinGameInfo { + return if (isMultiplayer) ServerAPI.joinMultiplayer() + else ServerAPI.joinLocal() + } + + private fun getGameState(): GameState { + return if (isMultiplayer) ServerAPI.getGameStateMultiplayer() + else ServerAPI.getGameStateLocal() + } + + private fun move(oldPos: Position, newPos: Position): GameMove { + return if (isMultiplayer) ServerAPI.moveMultiplayer(oldPos, newPos) + else ServerAPI.moveLocal(oldPos, newPos) + } + + private fun disconnect(currPos: Position) { + if (isMultiplayer) ServerAPI.disconnectMultiplayer(currPos) + else ServerAPI.disconnectLocal(currPos) + } + + private fun isYourTurn(): Boolean { + return currPlayer == ServerAPI.nickname + } +} \ No newline at end of file diff --git a/game-client/src/main/kotlin/ru/ifmo/sd/stuff/MapTextPane.kt b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/MapTextPane.kt new file mode 100644 index 0000000..a09b2ea --- /dev/null +++ b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/MapTextPane.kt @@ -0,0 +1,25 @@ +package ru.ifmo.sd.stuff + +import java.awt.Color +import java.awt.Color.* +import javax.swing.JTextPane +import javax.swing.text.Style +import javax.swing.text.StyleConstants + +class MapTextPane : JTextPane() { + private val FONT_FAMILY = "Monospaced" + private val FONT_SIZE = 24 + val colorMap: MutableMap = mutableMapOf() + private val colors = arrayOf(BLACK, RED, YELLOW, GREEN, MAGENTA, ORANGE, BLUE, GRAY) + + init { + isEditable = false + for (color in colors) { + val newStyle = addStyle(color.toString(), null) + StyleConstants.setFontFamily(newStyle, FONT_FAMILY) + StyleConstants.setFontSize(newStyle, FONT_SIZE) + colorMap[color] = newStyle + StyleConstants.setForeground(newStyle, color) + } + } +} \ No newline at end of file diff --git a/game-client/src/main/kotlin/ru/ifmo/sd/stuff/ServerAPI.kt b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/ServerAPI.kt new file mode 100644 index 0000000..fc537c9 --- /dev/null +++ b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/ServerAPI.kt @@ -0,0 +1,170 @@ +package ru.ifmo.sd.stuff + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import ru.ifmo.sd.httpapi.models.* +import java.io.IOException +import java.net.ServerSocket + + +object ServerAPI { + var client: HttpClient? = null + private const val MULTIPLAYER_ADDRESS = "http://localhost:8080" + private val LOCAL_ADDRESS + get() = "http://localhost:${port}" + private var mapSize = Pair(4, 4) + private var port = 8081 + internal var nickname: String? = "null" + private const val localNickname = "local" + private var localServerProc: Process? = null; + + internal fun initLocalServer() { + while (true) { + println("Checking avaliabilty of port=${port}") + if (isAvailable(port)) { + break + } else port++ + } + println("Trying to start local server at port=${port}") + println(System.getProperty("user.dir")) + val cmd = "../game-server/build/install/game-server/bin/game-server" // TODO call .bat for windows +// val env = arrayOf("PORT=${port}", "JAVA_HOME=/Users/macbook/.jenv/versions/1.8") // TODO JAVA_HOME + val env = arrayOf("PORT=${port}") + localServerProc = Runtime.getRuntime().exec(cmd, env) // TODO kill it after closing the client + while (true) { + var started = true + try { + joinLocal() + } catch (e: Exception) { + println(e.localizedMessage) + started = false + } + if (started) break + } + println("Local server successfully started") + } + + internal fun killLocalServer() { + if (localServerProc?.isAlive == true) localServerProc?.destroy() + } + +// internal fun startLocal() { +// runBlocking { +// println("apiStartLocal") +// client!!.post("$LOCAL_ADDRESS/start") { +// contentType(ContentType.Application.Json) +// body = LevelConfiguration(length = mapSize.first, width = mapSize.second) +// } +// } +// } +// +// internal fun startMultiplayer() { +// runBlocking { +// println("apiStartMult") +// client!!.post("$MULTIPLAYER_ADDRESS/start") { +// contentType(ContentType.Application.Json) +// body = LevelConfiguration(length = mapSize.first, width = mapSize.second) +// } +// } +// } + + internal fun joinLocal(): JoinGameInfo { + return runBlocking { +// println("apiJoinLocal") + return@runBlocking client!!.post("$LOCAL_ADDRESS/join") { + contentType(ContentType.Application.Json) + body = JoinInfo(localNickname, length = mapSize.first, width = mapSize.second) + } + } + } + + internal fun joinMultiplayer(): JoinGameInfo { + return runBlocking { + println("apiJoinMult") + return@runBlocking client!!.post("$MULTIPLAYER_ADDRESS/join") { + contentType(ContentType.Application.Json) + body = JoinInfo(nickname!!, length = mapSize.first, width = mapSize.second) + } + } + } + + internal fun getGameStateLocal(): GameState { + return runBlocking { + return@runBlocking client!!.get("$LOCAL_ADDRESS/gameState") { + parameter("playerName", localNickname) + } + } + } + + internal fun getGameStateMultiplayer(): GameState { + return runBlocking { + return@runBlocking client!!.get("$MULTIPLAYER_ADDRESS/gameState") { + parameter("playerName", nickname!!) + } + } + } + + internal fun moveLocal(oldPos: Position, newPos: Position): GameMove { + return runBlocking { + println("apiMoveLocal oldPos=$oldPos, newPos=$newPos") + return@runBlocking client!!.post("$LOCAL_ADDRESS/move") { + contentType(ContentType.Application.Json) + body = PlayerPositionChanging(localNickname, oldPos, newPos) + } + } + } + + internal fun moveMultiplayer(oldPos: Position, newPos: Position): GameMove { + return runBlocking { + println("apiMoveMult oldPos=$oldPos, newPos=$newPos") + return@runBlocking client!!.post("$MULTIPLAYER_ADDRESS/move") { + contentType(ContentType.Application.Json) + body = PlayerPositionChanging(nickname!!, oldPos, newPos) + } + } + } + + internal fun disconnectLocal(currPos: Position) { + runBlocking { + println("apiDisconnectLocal") + client!!.post("$LOCAL_ADDRESS/disconnect") { + contentType(ContentType.Application.Json) + body = PlayerInfo(localNickname, currPos) + } + } + } + + internal fun disconnectMultiplayer(currPos: Position) { + runBlocking { + println("apiDisconnectMultiplayer") + client!!.post("$MULTIPLAYER_ADDRESS/disconnect") { + contentType(ContentType.Application.Json) + body = PlayerInfo(nickname!!, currPos) + } + } + } + + // utilities + + internal fun increaseMapSize() { + if (mapSize.first < 9) { + mapSize = Pair(mapSize.first + 1, mapSize.second + 2) + } + } + + internal fun resetMapSize() { + mapSize = Pair(4, 4) + } + + private fun isAvailable(portNr: Int): Boolean { + var portFree: Boolean + try { + ServerSocket(portNr).use { portFree = true } + } catch (e: IOException) { + portFree = false + } + return portFree + } +} \ No newline at end of file diff --git a/game-client/src/main/kotlin/ru/ifmo/sd/stuff/SymbolMap.kt b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/SymbolMap.kt new file mode 100644 index 0000000..9c8bfd4 --- /dev/null +++ b/game-client/src/main/kotlin/ru/ifmo/sd/stuff/SymbolMap.kt @@ -0,0 +1,70 @@ +package ru.ifmo.sd.stuff + +import ru.ifmo.sd.httpapi.models.GameState +import ru.ifmo.sd.httpapi.models.JoinGameInfo +import ru.ifmo.sd.httpapi.models.MazeEventData +import ru.ifmo.sd.stuff.ColoredSymbol.* +import java.awt.Color +import java.awt.Color.* + + +enum class ColoredSymbol(val char: Char, val color: Color = BLACK) { + WALL(0x2591.toChar(), GRAY), + PLAYER(0x2689.toChar()/*, RED*/), + PASSIVE_ENEMY(0x2640.toChar()), + AGGRESSIVE_ENEMY(0x263F.toChar()), + COWARD_ENEMY(0x2649.toChar()), + NONE(' '), +} + +class SymbolMap { + + val rows: List> + val rowSize: Int + get() = rows.size + val columnSize: Int + get() = rows[0].size + var enemyAmount: Int + private set + + constructor(config: JoinGameInfo) { + this.rows = config.maze.levelMaze.map { arr -> + arr.map { i -> mazeObjToSymbol(i) }.toMutableList() + } + this.enemyAmount = config.maze.levelMaze.sumBy { arr -> arr.sumBy { i -> if (isEnemy(i)) 1 else 0 } } + rows[config.playerPos.row][config.playerPos.column] = PLAYER + } + + constructor(config: GameState) { + this.rows = config.maze.levelMaze.map { arr -> + arr.map { i -> mazeObjToSymbol(i) }.toMutableList() + } + this.enemyAmount = config.maze.levelMaze.sumBy { arr -> arr.sumBy { i -> if (isEnemy(i)) 1 else 0 } } + } + + private fun mazeObjToSymbol(mazeObj: Int): ColoredSymbol = when (mazeObj) { + 0 -> NONE + 1 -> WALL + 2 -> PLAYER + 3 -> PASSIVE_ENEMY + 4 -> AGGRESSIVE_ENEMY + 5 -> COWARD_ENEMY + else -> throw IllegalArgumentException("No such index in maze: $mazeObj") + } + + internal fun applyDiff(events: List) { + for (e in events) { + val pos = e.position + + val wasEnemy = isEnemy(rows[pos.row][pos.column]) + val willBeEnemy = isEnemy(e.newMazeObj) + if (wasEnemy && !willBeEnemy) enemyAmount -= 1 + if (!wasEnemy && willBeEnemy) enemyAmount += 1 + + rows[pos.row][pos.column] = mazeObjToSymbol(e.newMazeObj) + } + } + + private fun isEnemy(i: Int): Boolean = i == 3 || i == 4 || i == 5 + private fun isEnemy(i: ColoredSymbol): Boolean = i == PASSIVE_ENEMY || i == AGGRESSIVE_ENEMY || i == COWARD_ENEMY +} \ No newline at end of file diff --git a/game-server/build.gradle.kts b/game-server/build.gradle.kts new file mode 100644 index 0000000..e7e646a --- /dev/null +++ b/game-server/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + java + application + kotlin("jvm") version "1.4.21" + kotlin("plugin.serialization") version "1.4.21" +} + +group = "ru.ifmo.jb" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib")) + + implementation("ch.qos.logback:logback-classic:1.2.3") + + implementation("io.ktor:ktor-server-core:1.5.2") + implementation("io.ktor:ktor-server-netty:1.5.2") + implementation("io.ktor:ktor-serialization:1.5.2") + testImplementation("io.ktor:ktor-server-tests:1.5.2") +} + +application { + mainClassName = "ru.ifmo.sd.httpapi.ApplicationKt" +} + +tasks { + compileKotlin { + kotlinOptions.jvmTarget = "1.8" + } + compileTestKotlin { + kotlinOptions.jvmTarget = "1.8" + } +} + +tasks.test { + useJUnitPlatform() +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/Application.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/Application.kt new file mode 100644 index 0000000..bd00812 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/Application.kt @@ -0,0 +1,15 @@ +package ru.ifmo.sd.httpapi + +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.serialization.* +import ru.ifmo.sd.httpapi.routes.registerGameRoutes + +fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) + +fun Application.module(testing: Boolean = false) { + install(ContentNegotiation) { + json() + } + registerGameRoutes() +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/GameMove.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/GameMove.kt new file mode 100644 index 0000000..d3b8b2b --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/GameMove.kt @@ -0,0 +1,15 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MazeEventData( + val position: Position, + val newMazeObj: Int +) + +@Serializable +data class GameMove( + val playerPosition: Position, + val events: List +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/GameState.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/GameState.kt new file mode 100644 index 0000000..2c55da1 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/GameState.kt @@ -0,0 +1,12 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class GameState( + val currentMovePlayerName: String, + val isAlive: Boolean, + val isAtLeastOneNpcAlive: Boolean, + val maze: MazeData, + val unitsHealth: List> +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/JoinGameInfo.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/JoinGameInfo.kt new file mode 100644 index 0000000..d8ae667 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/JoinGameInfo.kt @@ -0,0 +1,28 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MazeData( + val levelMaze: Array> +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MazeData) return false + + if (!levelMaze.contentDeepEquals(other.levelMaze)) return false + + return true + } + + override fun hashCode(): Int { + return levelMaze.contentDeepHashCode() + } +} + +@Serializable +data class JoinGameInfo( + val playerPos: Position, + val maze: MazeData, + val unitsHealth: List> +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/JoinInfo.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/JoinInfo.kt new file mode 100644 index 0000000..3d7757c --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/JoinInfo.kt @@ -0,0 +1,10 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class JoinInfo( + val playerName: String, + val length: Int?, + val width: Int? +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/LevelConfiguration.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/LevelConfiguration.kt new file mode 100644 index 0000000..3537bff --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/LevelConfiguration.kt @@ -0,0 +1,6 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class LevelConfiguration(val length: Int, val width: Int) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/PlayerInfo.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/PlayerInfo.kt new file mode 100644 index 0000000..80409a9 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/PlayerInfo.kt @@ -0,0 +1,9 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerInfo( + val name: String, + val position: Position +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/PlayerPositionChanging.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/PlayerPositionChanging.kt new file mode 100644 index 0000000..fbc6bc8 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/PlayerPositionChanging.kt @@ -0,0 +1,10 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerPositionChanging( + val name: String, + val oldPosition: Position, + val newPosition: Position +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/Position.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/Position.kt new file mode 100644 index 0000000..fab2ca2 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/models/Position.kt @@ -0,0 +1,28 @@ +package ru.ifmo.sd.httpapi.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Position(val row: Int, val column: Int) { + operator fun plus(other: Position): Position { + return Position(row + other.row, column + other.column) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Position + + if (row != other.row) return false + if (column != other.column) return false + + return true + } + + override fun hashCode(): Int { + var result = row + result = 31 * result + column + return result + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/routes/GameRoutes.kt b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/routes/GameRoutes.kt new file mode 100644 index 0000000..5250b01 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/httpapi/routes/GameRoutes.kt @@ -0,0 +1,67 @@ +package ru.ifmo.sd.httpapi.routes + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import ru.ifmo.sd.httpapi.models.* +import ru.ifmo.sd.world.errors.GameServerException +import ru.ifmo.sd.world.events.EventsHandler + +fun Route.gameRouting() { + route("/") { + get("/gameState") { + val playerName = call.request.queryParameters["playerName"] + if (playerName == null) { + call.respond(HttpStatusCode.BadRequest, "Cannot get game state.") + } else { + val gameState = try { + EventsHandler.getActualGameState(playerName) + } catch (e: GameServerException) { + call.respond(HttpStatusCode.BadRequest, e.localizedMessage) + } + call.respond(HttpStatusCode.Accepted, gameState) + } + } + + post("/join") { + val joinInfo = call.receive() + val startedGame = try { + EventsHandler.join(joinInfo.playerName, joinInfo.length, joinInfo.width) + } catch (e: GameServerException) { + call.respond(message = e.localizedMessage, status = HttpStatusCode.BadRequest) + } + call.respond(message = startedGame, status = HttpStatusCode.Created) + } + + post("/disconnect") { + val playerInfo = call.receive() + val startedGame = try { + EventsHandler.disconnect(playerInfo.name, playerInfo.position) + } catch (e: GameServerException) { + call.respond(message = e.localizedMessage, status = HttpStatusCode.BadRequest) + } + call.respond(message = startedGame, status = HttpStatusCode.Created) + } + + post("/move") { + val playerMove = call.receive() + val move = try { + EventsHandler.move( + playerMove.name, playerMove.oldPosition, + playerMove.newPosition, EventsHandler.gameLevel!! + ) + } catch (e: GameServerException) { + call.respond(message = e.localizedMessage, status = HttpStatusCode.BadRequest) + } + call.respond(message = move, status = HttpStatusCode.Accepted) + } + } +} + +fun Application.registerGameRoutes() { + routing { + gameRouting() + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/errors/GameServerException.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/errors/GameServerException.kt new file mode 100644 index 0000000..7a5c201 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/errors/GameServerException.kt @@ -0,0 +1,3 @@ +package ru.ifmo.sd.world.errors + +class GameServerException(message: String): Exception(message) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/events/ChangeMazePositionEvent.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/events/ChangeMazePositionEvent.kt new file mode 100644 index 0000000..d0747ea --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/events/ChangeMazePositionEvent.kt @@ -0,0 +1,24 @@ +package ru.ifmo.sd.world.events + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.representation.units.MazeObject + +/** + * Класс события изменения игрового объекта на заданной позиции. + */ +data class ChangeMazePositionEvent(val position: Position, val newMazeObj: MazeObject?) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChangeMazePositionEvent + + if (position != other.position) return false + + return true + } + + override fun hashCode(): Int { + return position.hashCode() + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/events/EventsHandler.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/events/EventsHandler.kt new file mode 100644 index 0000000..40b6ce9 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/events/EventsHandler.kt @@ -0,0 +1,217 @@ +package ru.ifmo.sd.world.events + +import ru.ifmo.sd.httpapi.models.* +import ru.ifmo.sd.world.errors.GameServerException +import ru.ifmo.sd.world.generation.LevelGenerator +import ru.ifmo.sd.world.representation.* +import ru.ifmo.sd.world.representation.units.Player +import java.util.* +import java.util.stream.Collectors + + +/** + * Класс, отвечающий за обработку игровых событий, приходящих с клиента. + */ +object EventsHandler { + val interactionExecutor = InteractionExecutorImpl() + var gameLevel: GameLevel? = null + var playersQueue: Queue = LinkedList() + + /** + * Присоединяет нового игрока к текущей игровой сессии, если таковая есть, или создает новую. + * + * @param playerName -- имя нового игрока + * @param length -- длина игрового уровня + * @param width -- ширина игрового уровня + * @return начальную информацию об игровом уровне + */ + fun join(playerName: String, length: Int?, width: Int?): JoinGameInfo { + if (playersQueue.contains(playerName)) { + throw GameServerException("Player with $playerName name already exists.") + } + if (gameLevel == null) { + startGame(length, width) + } + val maze = gameLevel!!.maze + val newPlayerPos = maze.freePos.random() + gameLevel!!.unitsHealthStorage.addUnit(newPlayerPos) + maze[newPlayerPos] = Player() + playersQueue.add(playerName) + + return JoinGameInfo( + newPlayerPos, MazeData(getMazeData(gameLevel!!.maze)), + getHealthsData(gameLevel!!.unitsHealthStorage) + ) + } + + private fun getMazeData(maze: Maze): Array> { + val mazeData = Array(maze.levelMaze.size) { + Array(maze.levelMaze[0].size) { 0 } + } + for (i in mazeData.indices) { + for (j in mazeData[0].indices) { + val mazeObj = maze[Position(i, j)] + mazeData[i][j] = mazeObj?.getTypeIdentifier() ?: 0 + } + } + return mazeData + } + + private fun getHealthsData(unitsHealthStorage: UnitsHealthStorage): List> { + return unitsHealthStorage + .getHealthsDictionary() + .entries + .stream() + .map { e -> Pair(e.key, e.value) } + .collect(Collectors.toList()) + } + + /** + * Отсоединяет указанного игрока от игры. + * @param playerName -- имя игрока для отсоединения + * @param playerPos -- позиция игрока для отсоединения + */ + fun disconnect(playerName: String, playerPos: Position) { + if (gameLevel == null) { + throw GameServerException("Game level has not been initialized yet.") + } + val wasRemoved = playersQueue.remove(playerName) + if (!wasRemoved) { + throw GameServerException("Could not find player with name ${playerName}.") + } + val healths = gameLevel!!.unitsHealthStorage + healths.eliminateUnit(playerPos) + val maze = gameLevel!!.maze + maze[playerPos] = null + + if (playersQueue.isEmpty()) { + closeGame() + } + } + + /** + * Возвращает актуальное состояние игры. + * + * @return актуальное состояние игры + */ + fun getActualGameState(playerName: String): GameState { + if (gameLevel == null) { + throw GameServerException("Game level has not been initialized yet.") + } + if (playersQueue.isEmpty()) { + throw GameServerException("No player connected on level.") + } + return GameState( + playersQueue.peek(), playersQueue.contains(playerName), + gameLevel!!.npcEventProvider.isAtLeastOneNpcAlive(), + MazeData(getMazeData(gameLevel!!.maze)), + getHealthsData(gameLevel!!.unitsHealthStorage) + ) + } + + private fun startGame(length: Int?, width: Int?) { + gameLevel = if (length == null && width == null) { + LevelGenerator.generateLevel() + } else if (length == null) { + LevelGenerator.generateLevel(width!!) + } else if (width == null) { + LevelGenerator.generateLevel(length) + } else { + LevelGenerator.generateLevel( + length, + width + ) + } + } + + private fun closeGame() { + gameLevel = null + } + + /** + * Выполняет игровое действие игрока на заданной позиции + * по отношению к игровому объекту на заданной позиции. + * + * @param playerName -- имя игрока + * @param playerPos -- позиция игрока + * @param targetPos -- позиция игрового объекта + * @return данные об изменениях после игрового хода + */ + fun move( + playerName: String, playerPos: Position, + targetPos: Position, gameLevel: GameLevel + ): GameMove { + if (!playerName.contentEquals(playersQueue.peek())) { + throw GameServerException("Player with name $playerName cannot move because it is not his time to move.") + } + if (playersQueue.isEmpty()) { + throw GameServerException("No player connected on level.") + } + playersQueue.add(playersQueue.poll()) + return if (gameLevel.maze[targetPos] == null) { + moveToFreePos(playerName, playerPos, targetPos, gameLevel) + } else { + moveToOccupiedPos(playerName, playerPos, targetPos, gameLevel) + } + } + + private fun moveToFreePos( + playerName: String, playerPos: Position, + targetPos: Position, gameLevel: GameLevel + ): GameMove { + val maze = gameLevel.maze + val healths = gameLevel.unitsHealthStorage + // ход игрока + maze[playerPos] = null + val playerHealth = healths.eliminateUnit(playerPos)!! + maze[targetPos] = Player() + healths.addUnit(targetPos, playerHealth) + + // результат хода игрового мира + val npcMove = gameLevel.npcEventProvider.move(targetPos, maze) + npcMove.forEach { maze[it.position] = it.newMazeObj } + val newPlayerPos = + if (maze[targetPos] != null) { + targetPos + } else { + healths.eliminateUnit(targetPos) + playersQueue.remove(playerName) + Position(-1, -1) + } + return GameMove(newPlayerPos, npcMove.map { + MazeEventData( + it.position, + it.newMazeObj?.getTypeIdentifier() ?: 0 + ) + }) + } + + private fun moveToOccupiedPos( + playerName: String, playerPos: Position, + targetPos: Position, gameLevel: GameLevel + ): GameMove { + val maze = gameLevel.maze + // результат хода игрока + val playerMove = maze[targetPos]!!.interact(interactionExecutor, targetPos) + playerMove.forEach { maze[it.position] = it.newMazeObj } + + // результат хода игрового мира + val npcMove = gameLevel.npcEventProvider.move(playerPos, maze) + npcMove.forEach { maze[it.position] = it.newMazeObj } + val newPlayerPos = + if (maze[playerPos] != null) playerPos + else { + playersQueue.remove(playerName) + Position(-1, -1) + } + + playerMove.forEach { npcMove.add(it) } + + return GameMove(newPlayerPos, npcMove.map { + MazeEventData( + it.position, + it.newMazeObj?.getTypeIdentifier() ?: 0 + ) + }) + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/events/InteractionExecutorImpl.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/events/InteractionExecutorImpl.kt new file mode 100644 index 0000000..a35f900 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/events/InteractionExecutorImpl.kt @@ -0,0 +1,74 @@ +package ru.ifmo.sd.world.events + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.errors.GameServerException +import ru.ifmo.sd.world.representation.units.* + + +/** + * Интерфейс классов, отвечающих за выполнение взаимодействия с объектом на заданной позиции. + */ +interface InteractionExecutor { + /** + * Выполняет взаимодействие со стеной. + * + * @param obj -- тип объекта + * @param objPos -- позиция объекта + * @return множество событий изменения лабиринта + */ + fun doFor(obj: Wall, objPos: Position): MutableSet + + /** + * Выполняет взаимодействие с противником. + * + * @param obj -- тип объекта + * @param objPos -- позиция объекта + * @return множество событий изменения лабиринта + */ + fun doFor(obj: Enemy, objPos: Position): MutableSet + + /** + * Выполняет взаимодействие с игроком. + * + * @param obj -- тип объекта + * @param objPos -- позиция объекта + * @return множество событий изменения лабиринта + */ + fun doFor(obj: Player, objPos: Position): MutableSet +} + +class InteractionExecutorImpl : InteractionExecutor { + companion object { + const val playerDamage = 25 + const val npcDamage = 10 + } + + override fun doFor(obj: Wall, objPos: Position): MutableSet { + return HashSet() + } + + override fun doFor(obj: Enemy, objPos: Position): MutableSet { + val unitsHealthStorage = EventsHandler.gameLevel?.unitsHealthStorage + ?: throw GameServerException("Game level is not initialized!") + unitsHealthStorage.decrease(objPos, playerDamage) + return if (!unitsHealthStorage.isAlive(objPos)) { + unitsHealthStorage.eliminateUnit(objPos) + EventsHandler.gameLevel!!.npcEventProvider.eliminateNpc(objPos) + mutableSetOf(ChangeMazePositionEvent(objPos, null)) + } else { + HashSet() + } + } + + override fun doFor(obj: Player, objPos: Position): MutableSet { + val unitsHealthStorage = EventsHandler.gameLevel?.unitsHealthStorage + ?: throw GameServerException("Game level is not initialized!") + unitsHealthStorage.decrease(objPos, npcDamage) + return if (!unitsHealthStorage.isAlive(objPos)) { + unitsHealthStorage.eliminateUnit(objPos) + mutableSetOf(ChangeMazePositionEvent(objPos, null)) + } else { + HashSet() + } + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/generation/LevelGenerator.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/generation/LevelGenerator.kt new file mode 100644 index 0000000..1d596ba --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/generation/LevelGenerator.kt @@ -0,0 +1,148 @@ +package ru.ifmo.sd.world.generation + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.npc.NpcEventProvider +import ru.ifmo.sd.world.npc.strategy.* +import ru.ifmo.sd.world.representation.* +import ru.ifmo.sd.world.representation.units.Wall +import kotlin.properties.Delegates +import kotlin.random.Random + +/** + * Класс, отвечающий за генерацию игрового лабиринта и + * размещения на нем противников. + */ +class LevelGenerator { + companion object { + private var gameDifficulty by Delegates.notNull() + private lateinit var level: Array + private lateinit var freePos: MutableList + + /** + * Метод для генерации случайного лабиринта. + * + * @param length -- длина лабиринта + * @param width -- ширина лабиринта + * @return двумерный массив соответствующий игровому лабиринту + */ + fun generateLevel(length: Int = 10, width: Int = 10, difficulty: Float = 0.1F): GameLevel { + gameDifficulty = difficulty + /* + Шаг смещения позиции при создании лабиринта определим равным 2. + Создаем лабиринт с нечетными размерностями, чтобы поддерживать + инвариант нахождения в границах игрового поля. + */ + val actualLength = if (length < 4) 9 else 2 * length + 1 + val actualWidth = if (width < 4) 9 else 2 * width + 1 + // Изначально лабиринт заполняем стенками + level = Array(actualLength) { IntArray(actualWidth) { 1 } } + + // Позиция входа в лабиринт + val enterPosRow = 0 + val enterPosColumn = 1 + level[enterPosRow][enterPosColumn] = 0 + + // Позиция выхода из лабиринта + val exitPosRow = actualLength - 1 + val exitPosColumn = actualWidth - 2 + level[exitPosRow][exitPosColumn] = 0 + + // Стартовая позиция для генерации + val startPosRow = 1 + val startPosColumn = 1 + level[startPosRow][startPosColumn] = 0 + + freePos = ArrayList() + // Рекурсивно генерируем лабиринт + generateRec(startPosRow, startPosColumn) + + val maze = Maze(Array(actualLength) { + Array(actualWidth) { + null + } + }, HashSet()) + fillMaze(maze) + val unitsHealths = UnitsHealthStorage() + val npc = NpcEventProvider() + placeEnemies(maze, unitsHealths, npc) + return GameLevel(maze, unitsHealths, npc) + } + + private fun fillMaze(maze: Maze) { + for (i in level.indices) { + for (j in level[0].indices) { + if (level[i][j] == 1) maze[Position(i, j)] = Wall() + } + } + } + + // Идея в том, чтобы пробуривать соседние занятые клетки и избегать при этом циклов. + private fun generateRec(curRow: Int, curColumn: Int) { + // Идентификатор текущего направления движения при генерации лабиринта + var i: Int + // Массив доступных направлении и смещения в этих направлениях + val dirs: Array = Array(3) { IntArray(2) { 0 } } + // Двигаемся по лабиринту пока можем + while (true) { + i = 0 + /* + Если текущая позиция в границах игрового поля, и через клетку не свободно, + иначе цикл, то пробуриваемся в текущем направлении. + */ + if (curRow > 1 && level[curRow - 2][curColumn] != 0) { + dirs[i][0] = curRow - 2 + dirs[i][1] = curColumn + i++ + } + if (curRow < level.size - 2 && level[curRow + 2][curColumn] != 0) { + dirs[i][0] = curRow + 2 + dirs[i][1] = curColumn + i++ + } + if (curColumn > 1 && level[curRow][curColumn - 2] != 0) { + dirs[i][0] = curRow + dirs[i][1] = curColumn - 2 + i++ + } + if (curColumn < level[0].size - 2 && level[curRow][curColumn + 2] != 0) { + dirs[i][0] = curRow + dirs[i][1] = curColumn + 2 + i++ + } + // Если доступных направлений для движения нет, то прекращаем работу + if (i == 0) break + // Выбираем случайное направление для следующего движения + i = (i * Random.nextDouble()).toInt() + // Пробуриваем две стенки в направлении движения + val firstPosToRemoveWall = Position( + (dirs[i][0] + curRow) / 2, + (dirs[i][1] + curColumn) / 2 + ) + val secondPosToRemoveWall = Position(dirs[i][0], dirs[i][1]) + level[firstPosToRemoveWall.row][firstPosToRemoveWall.column] = 0 + level[secondPosToRemoveWall.row][secondPosToRemoveWall.column] = 0 + + freePos.add(firstPosToRemoveWall) + freePos.add(secondPosToRemoveWall) + + // Рекурсивно запускаемся от позиции, в которой оказались после пробуривания стенок + generateRec(dirs[i][0], dirs[i][1]) + } + } + + private fun placeEnemies(maze: Maze, healths: UnitsHealthStorage, npc: NpcEventProvider) { + val countOfEnemies = (maze.size() * gameDifficulty).toInt() + freePos.forEach { maze.freePos.add(it) } + val enemiesPositions = freePos + .shuffled() + .take(countOfEnemies) + val strategies = listOf(Passive(), Aggressive(), Coward()) + enemiesPositions.forEach { + val chosenStrategy = strategies.random() + maze[it] = chosenStrategy.getEnemyFactory().getEnemy() + healths.addUnit(it, 100) + npc.addNpc(it, chosenStrategy) + } + } + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/NpcEventProvider.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/NpcEventProvider.kt new file mode 100644 index 0000000..438f364 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/NpcEventProvider.kt @@ -0,0 +1,68 @@ +package ru.ifmo.sd.world.npc + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.ChangeMazePositionEvent +import ru.ifmo.sd.world.npc.strategy.Strategy +import ru.ifmo.sd.world.representation.Maze + +/** + * Класс NPC. + */ +class Npc(var position: Position, private val strategy: Strategy) { + /** + * Выполняет игровой ход текущего NPC по отношению к игровому персонажу на заданной позиции. + * + * @param playerPos -- позиция игрового персонажа + * @param maze -- игровой лабиринт + * @return множество событий игрового лабиринта + */ + fun move(playerPos: Position, maze: Maze): MutableSet { + return strategy.execute(this, playerPos, maze) + } +} + +/** + * Класс, отвечающий за игровые действия NPC. + */ +class NpcEventProvider { + private val npc: MutableSet = HashSet() + + /** + * Выполняет игровое действие случайного NPC по отношению к игровому персонажу на заданной позиции. + * + * @param playerPos -- позиция игрового персонажа + * @param maze -- игровой лабиринт + * @return множество событий игрового лабиринта + */ + fun move(playerPos: Position, maze: Maze): MutableSet { + return if (npc.isNotEmpty()) npc.random().move(playerPos, maze) else HashSet() + } + + /** + * Добавляет нового NPC с заданной стратегией на заданную позицию. + * + * @param pos -- позиция нового NPC + * @param strategy -- стратегия поведения нового NPC + */ + fun addNpc(pos: Position, strategy: Strategy) { + npc.add(Npc(pos, strategy)) + } + + /** + * Уничтожает NPC на заданной позиции. + * + * @param pos -- позиция NPC + */ + fun eliminateNpc(pos: Position) { + npc.removeIf { it.position == pos } + } + + /** + * Возвращает флаг выживания npc. + * + * @return true -- если хотя бы один npc жив, иначе -- false + */ + fun isAtLeastOneNpcAlive(): Boolean { + return npc.isNotEmpty() + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Aggressive.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Aggressive.kt new file mode 100644 index 0000000..a84f924 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Aggressive.kt @@ -0,0 +1,57 @@ +package ru.ifmo.sd.world.npc.strategy + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.ChangeMazePositionEvent +import ru.ifmo.sd.world.events.EventsHandler +import ru.ifmo.sd.world.npc.Npc +import ru.ifmo.sd.world.npc.strategy.Strategy.Companion.PlayerDirection +import ru.ifmo.sd.world.representation.Maze +import ru.ifmo.sd.world.representation.units.* +import kotlin.math.abs + + +/** + * Класс, отвечающий агрессивной стратегии поведения NPC. + */ +class Aggressive : Strategy { + private fun isAdjacent(npcPos: Position, playerPos: Position): Boolean { + return abs(npcPos.row - playerPos.row) == 1 && abs(npcPos.column - playerPos.column) == 0 + || abs(npcPos.row - playerPos.row) == 0 && abs(npcPos.column - playerPos.column) == 1 + } + + override fun getEnemyFactory(): EnemyFactory { + return AggressiveEnemyFactory() + } + + override fun execute(npc: Npc, playerPos: Position, maze: Maze): MutableSet = + when (val direction = Strategy.findPlayer(npc.position, playerPos, maze)) { + PlayerDirection.Failed -> + HashSet() + else -> + if (isAdjacent(npc.position, playerPos)) { + maze[playerPos]!!.interact(EventsHandler.interactionExecutor, playerPos) + } else { + val directionsToMove = getDirection(direction) + val randomPos = Strategy.randomDirection(npc.position, directionsToMove, maze) + if (randomPos != Position(0, 0)) { + val oldNpcPos = npc.position + npc.position = npc.position + randomPos + mutableSetOf( + ChangeMazePositionEvent(oldNpcPos, null), + ChangeMazePositionEvent(npc.position, getEnemyFactory().getEnemy()) + ) + } else { + HashSet() + } + } + } + + private fun getDirection(direction: PlayerDirection): List = + listOf(when (direction) { + PlayerDirection.North -> Position(-1, 0) + PlayerDirection.South -> Position(1, 0) + PlayerDirection.West -> Position(0, 1) + PlayerDirection.East -> Position(0, -1) + PlayerDirection.Failed -> Position(0, 0) + }) +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Coward.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Coward.kt new file mode 100644 index 0000000..562ac2e --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Coward.kt @@ -0,0 +1,68 @@ +package ru.ifmo.sd.world.npc.strategy + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.ChangeMazePositionEvent +import ru.ifmo.sd.world.npc.Npc +import ru.ifmo.sd.world.npc.strategy.Strategy.Companion.PlayerDirection +import ru.ifmo.sd.world.representation.Maze +import ru.ifmo.sd.world.representation.units.* + +/** + * Класс, отвечающий трусливой стратегии поведения NPC. + */ +class Coward : Strategy { + companion object { + private val north = listOf( + Position(1, 0), + Position(0, 1), + Position(0, -1) + ) + private val south = listOf( + Position(-1, 0), + Position(0, 1), + Position(0, -1) + ) + private val west = listOf( + Position(1, 0), + Position(0, -1), + Position(-1, 0) + ) + private val east = listOf( + Position(1, 0), + Position(0, 1), + Position(1, 0) + ) + } + + override fun getEnemyFactory(): EnemyFactory { + return CowardEnemyFactory() + } + + override fun execute(npc: Npc, playerPos: Position, maze: Maze): MutableSet = + when (val direction = Strategy.findPlayer(npc.position, playerPos, maze)) { + PlayerDirection.Failed -> HashSet() + else -> { + val directionsToMove = getDirections(direction) + val randomPos = Strategy.randomDirection(npc.position, directionsToMove, maze) + if (randomPos != Position(0, 0)) { + val oldNpcPos = npc.position + npc.position = npc.position + randomPos + mutableSetOf( + ChangeMazePositionEvent(oldNpcPos, null), + ChangeMazePositionEvent(npc.position, getEnemyFactory().getEnemy()) + ) + } else { + HashSet() + } + } + } + + private fun getDirections(direction: PlayerDirection): List = + when (direction) { + PlayerDirection.North -> north + PlayerDirection.South -> south + PlayerDirection.West -> west + PlayerDirection.East -> east + PlayerDirection.Failed -> emptyList() + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Passive.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Passive.kt new file mode 100644 index 0000000..30eaa70 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Passive.kt @@ -0,0 +1,36 @@ +package ru.ifmo.sd.world.npc.strategy + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.ChangeMazePositionEvent +import ru.ifmo.sd.world.npc.Npc +import ru.ifmo.sd.world.representation.Maze +import ru.ifmo.sd.world.representation.units.EnemyFactory +import ru.ifmo.sd.world.representation.units.PassiveEnemyFactory + +/** + * Класс, отвечающий пассивной стратегии поведения NPC. + */ +class Passive : Strategy { + companion object { + private val directionsToMove = listOf( + Position(0, 1), Position(0, -1), + Position(1, 0), Position(-1, 0) + ) + } + + override fun getEnemyFactory(): EnemyFactory { + return PassiveEnemyFactory() + } + + override fun execute(npc: Npc, playerPos: Position, maze: Maze): MutableSet { + val randomMove = Strategy.randomDirection(npc.position, directionsToMove, maze) + return if (randomMove != Position(0, 0)) { + val oldNpcPos = npc.position + npc.position = npc.position + randomMove + mutableSetOf( + ChangeMazePositionEvent(oldNpcPos, null), + ChangeMazePositionEvent(npc.position, getEnemyFactory().getEnemy()) + ) + } else HashSet() + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Strategy.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Strategy.kt new file mode 100644 index 0000000..7824321 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/npc/strategy/Strategy.kt @@ -0,0 +1,142 @@ +package ru.ifmo.sd.world.npc.strategy + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.ChangeMazePositionEvent +import ru.ifmo.sd.world.npc.Npc +import ru.ifmo.sd.world.npc.strategy.Strategy.Companion.PlayerDirection.* +import ru.ifmo.sd.world.representation.Maze +import ru.ifmo.sd.world.representation.units.EnemyFactory +import kotlin.math.abs +import kotlin.math.atan2 + +/** + * Интерфейс стратегии поведения NPC. + */ +interface Strategy { + /** + * Возвращает фабрику объектов лабиринта, соответствующих противникам с данной стратегией поведения. + * + * @return фабрика противников + */ + fun getEnemyFactory(): EnemyFactory + + /** + * Выполняет действие NPC на заданной позиции по отношению к игроку на заданной позиции. + * + * @param npc-- NPC, выполняющий действие + * @param playerPos -- позиция игрока + * @param maze -- игровой лабиринт + * @return множество событий изменения лабиринта + */ + fun execute(npc: Npc, playerPos: Position, maze: Maze): MutableSet + + companion object { + enum class PlayerDirection { + North, South, West, East, Failed; + } + + /** + * Проверяет нахождение игрового персонажа на заданной позиции + * в области видимости NPC на заданной позиции. Возвращает направление, + * в котором находится игровой персонаж относительно заданного NPC. + * Bresenham's algorithm, see en.wikipedia.org/wiki/File:Bresenham.svg + * + * @param npcPos -- позиция NPC + * @param playerPos -- позиция игрового персонажа + * @param maze -- игровой лабиринт + * @return направление или неудачу, если персонаж невидим для NPC + */ + fun findPlayer(npcPos: Position, playerPos: Position, maze: Maze): PlayerDirection { + var d = 0 + + val dx: Int = abs(playerPos.row - npcPos.row) + val dy: Int = abs(playerPos.column - npcPos.column) + + val dx2 = 2 * dx + val dy2 = 2 * dy + + val ix = if (npcPos.row < playerPos.row) 1 else -1 + val iy = if (npcPos.column < playerPos.column) 1 else -1 + + var x: Int = npcPos.row + var y: Int = npcPos.column + + if (dx >= dy) { + while (true) { + if ((x != npcPos.row || y != npcPos.column) + && (x != playerPos.row || y != playerPos.column) + && !checkVisibility(Position(x, y), maze)) { + return Failed + } + if (x == playerPos.row) break + x += ix + d += dy2 + if (d > dx) { + y += iy + d -= dx2 + } + } + } else { + while (true) { + if ((x != npcPos.row || y != npcPos.column) + && (x != playerPos.row || y != playerPos.column) + && !checkVisibility(Position(x, y), maze)) { + return Failed + } + if (y == playerPos.column) break + y += iy + d += dx2 + if (d > dy) { + x += ix + d -= dy2 + } + } + } + + return getDirection(npcPos, playerPos) + } + + private fun getDirection(npcPos: Position, playerPos: Position): PlayerDirection { + var rad = atan2((playerPos.row - npcPos.row).toDouble(), (playerPos.column - npcPos.column).toDouble()); + if (rad < 0) rad += (2 * Math.PI) + return when (rad * (180 / Math.PI)) { + in 45.0..135.0 -> { + South + } + in 135.0..225.0 -> { + East + } + in 225.0..315.0 -> { + North + } + else -> { + West + } + } + } + + private fun checkVisibility(pos: Position, maze: Maze): Boolean { + return maze[pos] == null + } + + /** + * Выбирает случайное перемещении NPC на заданной позиции. + * + * @param npcPos -- позиция NPC + * @param directions -- список позиций для выбора + * @param maze -- игровой лабиринт + * @return выбранное перемещение NPC или нулевое перемещение, если доступных перемещений нет + */ + fun randomDirection(npcPos: Position, directions: List, maze: Maze): Position { + val mazeLength = maze.levelMaze.size + val mazeWidth = maze.levelMaze[0].size + val availablePos = directions.filter { + val newNpcPos = npcPos + it + (newNpcPos.row in 1..mazeLength - 2 + && newNpcPos.column in 1..mazeWidth - 2 + && maze[newNpcPos] == null) + } + return if (availablePos.isNotEmpty()) availablePos.random() else Position(0, 0) + } + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/GameLevel.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/GameLevel.kt new file mode 100644 index 0000000..1030c2d --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/GameLevel.kt @@ -0,0 +1,9 @@ +package ru.ifmo.sd.world.representation + +import ru.ifmo.sd.world.npc.NpcEventProvider + +data class GameLevel( + val maze: Maze, + val unitsHealthStorage: UnitsHealthStorage, + val npcEventProvider: NpcEventProvider +) diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/Maze.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/Maze.kt new file mode 100644 index 0000000..a811e50 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/Maze.kt @@ -0,0 +1,17 @@ +package ru.ifmo.sd.world.representation + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.representation.units.MazeObject + +data class Maze(val levelMaze: Array>, val freePos: MutableSet) { + operator fun get(pos: Position): MazeObject? = levelMaze[pos.row][pos.column] + + operator fun set(position: Position, mazeObject: MazeObject?) { + if (levelMaze[position.row][position.column] == null) { + freePos.remove(position) + } + levelMaze[position.row][position.column] = mazeObject + } + + fun size() = levelMaze.size * levelMaze[0].size +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/UnitsHealthStorage.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/UnitsHealthStorage.kt new file mode 100644 index 0000000..1ff69cd --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/UnitsHealthStorage.kt @@ -0,0 +1,72 @@ +package ru.ifmo.sd.world.representation + +import ru.ifmo.sd.httpapi.models.Position + + +/** + * Хранилище информации о жизненных силах персонажа и NPC. + */ +class UnitsHealthStorage { + private val healths: MutableMap = HashMap() + + /** + * Возвращает словарь жизненных сил юнитов. + * + * @return словарь с жизненными силами юнитов + */ + fun getHealthsDictionary(): Map { + return healths + } + + /** + * Добавляет в хранилище нового юнита на заданную + * позицию с заданным количеством жизненных сил. + * + * @param pos -- позиция нового юнита + * @param value -- количество жизненных сил нового юнита + */ + fun addUnit(pos: Position, value: Int = 100) { + healths[pos] = value + } + + /** + * Уничтожает юнита на заданной позиции. + * + * @param pos -- позиция юнита + */ + fun eliminateUnit(pos: Position): Int? { + return healths.remove(pos) + } + + /** + * Увеличивает количество жизненных сил юнита на + * заданной позиции на заданное количество единиц. + * + * @param pos -- позиция юнита + * @param value -- количество жизненных сил + */ + fun increase(pos: Position, value: Int) { + healths.merge(pos, value, Int::plus) + } + + /** + * Уменьшает количество жизненных сил юнита на + * заданной позиции на заданное количество единиц. + * + * @param pos -- позиция юнита + * @param value -- количество жизненных сил + */ + fun decrease(pos: Position, value: Int) { + healths.merge(pos, value, Int::minus) + } + + /** + * Проверяет, что юнит на заданной позиции жив. + * + * @param pos -- позиция юнита + * @return флаг, соответствующий состоянию юнита + */ + fun isAlive(pos: Position): Boolean { + return healths.getOrDefault(pos, 0) > 0 + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/EnemyFactory.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/EnemyFactory.kt new file mode 100644 index 0000000..07f3d15 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/EnemyFactory.kt @@ -0,0 +1,29 @@ +package ru.ifmo.sd.world.representation.units + +/** + * Интерфейс фабрики противников. + */ +interface EnemyFactory { + /** + * Возвращает противника. + */ + fun getEnemy(): Enemy +} + +class AggressiveEnemyFactory : EnemyFactory { + override fun getEnemy(): Enemy { + return AggressiveEnemy() + } +} + +class PassiveEnemyFactory : EnemyFactory { + override fun getEnemy(): Enemy { + return PassiveEnemy() + } +} + +class CowardEnemyFactory : EnemyFactory { + override fun getEnemy(): Enemy { + return CowardEnemy() + } +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/MazeObject.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/MazeObject.kt new file mode 100644 index 0000000..d677a00 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/MazeObject.kt @@ -0,0 +1,27 @@ +package ru.ifmo.sd.world.representation.units + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.InteractionExecutor +import ru.ifmo.sd.world.events.ChangeMazePositionEvent + + +/** + * Интерфейс объекта игрового лабиринта. + */ +interface MazeObject { + /** + * Запускает взаимодействие с текущим объектом с помощью исполнителя взаимодействия. + * + * @param executor -- исполнитель взаимодействия + * @param mazeObjPos -- позиция текущего объекта + * @return множество событий изменения лабиринта + */ + fun interact(executor: InteractionExecutor, mazeObjPos: Position): MutableSet + + /** + * Возвращает идентификатор типа текущего объекта. + * + * @return идентификатор типа + */ + fun getTypeIdentifier(): Int +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/MazeUnit.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/MazeUnit.kt new file mode 100644 index 0000000..f2fa359 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/MazeUnit.kt @@ -0,0 +1,39 @@ +package ru.ifmo.sd.world.representation.units + +import kotlinx.serialization.Serializable +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.InteractionExecutor +import ru.ifmo.sd.world.events.ChangeMazePositionEvent + +interface MazeUnit : MazeObject + +@Serializable +class Player : MazeUnit { + override fun interact(executor: InteractionExecutor, mazeObjPos: Position): MutableSet { + return executor.doFor(this, mazeObjPos) + } + + override fun getTypeIdentifier() = 2 +} + +@Serializable +abstract class Enemy : MazeUnit { + override fun interact(executor: InteractionExecutor, mazeObjPos: Position): MutableSet { + return executor.doFor(this, mazeObjPos) + } +} + +@Serializable +class PassiveEnemy : Enemy() { + override fun getTypeIdentifier() = 3 +} + +@Serializable +class AggressiveEnemy : Enemy() { + override fun getTypeIdentifier() = 4 +} + +@Serializable +class CowardEnemy : Enemy() { + override fun getTypeIdentifier() = 5 +} diff --git a/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/Wall.kt b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/Wall.kt new file mode 100644 index 0000000..a0452c8 --- /dev/null +++ b/game-server/src/main/kotlin/ru/ifmo/sd/world/representation/units/Wall.kt @@ -0,0 +1,13 @@ +package ru.ifmo.sd.world.representation.units + +import ru.ifmo.sd.httpapi.models.Position +import ru.ifmo.sd.world.events.InteractionExecutor +import ru.ifmo.sd.world.events.ChangeMazePositionEvent + +class Wall : MazeObject { + override fun interact(executor: InteractionExecutor, mazeObjPos: Position): MutableSet { + return executor.doFor(this, mazeObjPos) + } + + override fun getTypeIdentifier() = 1 +} diff --git a/game-server/src/main/resources/application.conf b/game-server/src/main/resources/application.conf new file mode 100644 index 0000000..b661627 --- /dev/null +++ b/game-server/src/main/resources/application.conf @@ -0,0 +1,9 @@ +ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ ru.ifmo.sd.httpapi.ApplicationKt.module ] + } +} diff --git a/game-server/src/main/resources/logback.xml b/game-server/src/main/resources/logback.xml new file mode 100644 index 0000000..bdbb64e --- /dev/null +++ b/game-server/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 25ad449..7f3ab9b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,3 @@ -rootProject.name = "roguelike" \ No newline at end of file +rootProject.name = "roguelike" +include("game-client") +include("game-server") diff --git a/src/main/kotlin/ru/ifmo/sd/Main.kt b/src/main/kotlin/ru/ifmo/sd/Main.kt deleted file mode 100644 index 18da3f5..0000000 --- a/src/main/kotlin/ru/ifmo/sd/Main.kt +++ /dev/null @@ -1,164 +0,0 @@ -package ru.ifmo.sd - -import ru.ifmo.sd.stuff.* -import java.awt.BorderLayout -import java.awt.EventQueue -import java.awt.event.KeyEvent -import javax.swing.* -import javax.swing.text.* - - -class GUI(title: String) : JFrame() { - val mainPanel = JPanel() - val headerPanel = JPanel() - val mapPanel = JPanel() - val infoPanel = JPanel() - var map = SymbolMap(mapPreviewSymbols) - - init { - createUI(title) - } - - private fun createUI(title: String) { - setTitle(title) - -// createMenuBar() - createLayout() - - defaultCloseOperation = EXIT_ON_CLOSE - setSize(800, 600) - isResizable = false - setLocationRelativeTo(null) - } - - private fun createMenuBar() { - val menubar = JMenuBar() - val icon = ImageIcon("src/main/resources/exit.png") - - val file = JMenu("File") - file.mnemonic = KeyEvent.VK_F - - val eMenuItem = JMenuItem("Exit", icon) - eMenuItem.mnemonic = KeyEvent.VK_E - eMenuItem.toolTipText = "Exit application" - eMenuItem.addActionListener { System.exit(0) } - - file.add(eMenuItem) - menubar.add(file) - - jMenuBar = menubar - } - - private fun createLayout() { - add(mainPanel) - mainPanel.layout = BorderLayout() - - headerPanel.setSize(800, 50) - mainPanel.add(headerPanel, BorderLayout.NORTH) - headerPanel.add(JButton("hey")) - - mapPanel.setSize(550, 550) - mainPanel.add(mapPanel, BorderLayout.WEST) - mapPanel.add(MapTextPane) - createMap() - - infoPanel.setSize(250, 550) - mainPanel.add(infoPanel, BorderLayout.EAST) - infoPanel.add(JButton("Okaey"), BorderLayout.WEST) - } - - private fun createMap() { - for (i in 0 until SymbolMap.rowSize) { - if (i != 0) { - insertText("\n", MapTextPane.colorMap[MapSymbolColor.BLACK]!!) - } - for (j in 0 until SymbolMap.columnSize) { - val symbol = map.rows[i][j] - val style = MapTextPane.colorMap[symbol.color]!! - insertText(symbol.content.toString(), style) - } - } - } - - private fun insertText(string: String, style: Style) { - try { - val doc = MapTextPane.document - doc.insertString(doc.length, string, style) - } catch (e: Exception) { - e.printStackTrace() // TODO - } - } -} - -private fun createAndShowGUI() { - val frame = GUI("Roguelike") - frame.isVisible = true -} - -fun main() { - EventQueue.invokeLater(::createAndShowGUI) -} - - - - - - - - - - - - - - - - - - - - - - - - - -private val mapPreviewText = listOf( - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", - "qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09qwert 09", -) - -val mapPreviewSymbols: List> = - mapPreviewText.map { s -> - s.map { c -> Symbol(c, MapSymbolColor.BLACK) } - } \ No newline at end of file diff --git a/src/main/kotlin/ru/ifmo/sd/stuff/MapTextPane.kt b/src/main/kotlin/ru/ifmo/sd/stuff/MapTextPane.kt deleted file mode 100644 index 8ee5813..0000000 --- a/src/main/kotlin/ru/ifmo/sd/stuff/MapTextPane.kt +++ /dev/null @@ -1,29 +0,0 @@ -package ru.ifmo.sd.stuff - -import java.awt.Color -import javax.swing.JTextPane -import javax.swing.text.Style -import javax.swing.text.StyleConstants - -object MapTextPane : JTextPane() { - private val FONT_FAMILY = "Monospaced" - private val FONT_SIZE = 12 - val colorMap: MutableMap = mutableMapOf() - - init { - isEditable = false - for (color in MapSymbolColor.values()) { - val newStyle = addStyle(color.toString(), null) - StyleConstants.setFontFamily(newStyle, FONT_FAMILY) - StyleConstants.setFontSize(newStyle, FONT_SIZE) - colorMap[color] = newStyle - } - StyleConstants.setForeground(colorMap[MapSymbolColor.BLACK], Color.BLACK) - StyleConstants.setForeground(colorMap[MapSymbolColor.GREEN], Color.GREEN) - StyleConstants.setForeground(colorMap[MapSymbolColor.RED], Color.RED) - StyleConstants.setForeground(colorMap[MapSymbolColor.YELLOW], Color.YELLOW) - StyleConstants.setForeground(colorMap[MapSymbolColor.MAGENTA], Color.MAGENTA) - StyleConstants.setForeground(colorMap[MapSymbolColor.ORANGE], Color.ORANGE) - StyleConstants.setForeground(colorMap[MapSymbolColor.BLUE], Color.BLUE) - } -} \ No newline at end of file diff --git a/src/main/kotlin/ru/ifmo/sd/stuff/SymbolMap.kt b/src/main/kotlin/ru/ifmo/sd/stuff/SymbolMap.kt deleted file mode 100644 index 12eca35..0000000 --- a/src/main/kotlin/ru/ifmo/sd/stuff/SymbolMap.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ru.ifmo.sd.stuff - -enum class MapSymbolColor { - BLACK, GREEN, YELLOW, RED, BLUE, MAGENTA, ORANGE -} - -data class Symbol(val content: Char, val color: MapSymbolColor = MapSymbolColor.BLACK) - -data class SymbolMap(val rows: List> = List(rowSize) { List(columnSize) { Symbol(' ') } } ) { - companion object { - const val rowSize = 32 - const val columnSize = 64 - } -} \ No newline at end of file