├── src └── main │ ├── resources │ ├── strings_de_DE.properties │ ├── META-INF │ │ └── services │ │ │ ├── sc.gui.view.GameBoard │ │ │ ├── sc.api.plugins.IGamePlugin │ │ │ └── sc.networking.XStreamProvider │ ├── icon.png │ ├── hui │ │ ├── field_goal.png │ │ ├── field_hare.png │ │ ├── icon_hare.png │ │ ├── player_red.png │ │ ├── card_eatsalad.png │ │ ├── card_fallback.png │ │ ├── field_blank.png │ │ ├── field_carrots.png │ │ ├── field_market.png │ │ ├── field_salad.png │ │ ├── field_start.png │ │ ├── icon_hedgehog.png │ │ ├── player_blue.png │ │ ├── card_hurryahead.png │ │ ├── field_hedgehog.png │ │ ├── card_swapcarrots.png │ │ ├── field_position_1.png │ │ ├── field_position_2.png │ │ ├── card_exchangecarrots.png │ │ └── background_very_simple.png │ ├── mq │ │ ├── fields │ │ │ ├── goal.png │ │ │ ├── logs_A.png │ │ │ ├── logs_B.png │ │ │ ├── stream_A.png │ │ │ ├── stream_B.png │ │ │ ├── sandbank_A.png │ │ │ ├── sandbank_B.png │ │ │ ├── background │ │ │ │ ├── fog_A.png │ │ │ │ ├── fog_B.png │ │ │ │ ├── fog_C.png │ │ │ │ ├── fog_D.png │ │ │ │ ├── fog_E.png │ │ │ │ ├── fog_F.png │ │ │ │ ├── fog_tile.png │ │ │ │ ├── background.png │ │ │ │ ├── background_A.png │ │ │ │ ├── background_B.png │ │ │ │ ├── background_C.png │ │ │ │ ├── background_D.png │ │ │ │ ├── background_E.png │ │ │ │ ├── border_diagonal.png │ │ │ │ ├── border_vertical.png │ │ │ │ ├── fog_background.png │ │ │ │ ├── border_inner_corner.png │ │ │ │ ├── border_outer_corner.png │ │ │ │ ├── fog_border_diagonal.png │ │ │ │ ├── fog_border_vertical.png │ │ │ │ ├── fog_border_inner_corner.png │ │ │ │ ├── fog_border_outer_corner.png │ │ │ │ ├── fog_border_beach_diagonal.png │ │ │ │ ├── fog_border_beach_vertical.png │ │ │ │ ├── fog_border_beach_inner_corner.png │ │ │ │ └── fog_border_beach_outer_corner.png │ │ │ ├── islands │ │ │ │ ├── empty_island_A.png │ │ │ │ ├── empty_island_B.png │ │ │ │ ├── empty_island_D.png │ │ │ │ ├── passenger_island_a_0.png │ │ │ │ ├── passenger_island_a_1.png │ │ │ │ ├── passenger_island_b_0.png │ │ │ │ ├── passenger_island_b_1.png │ │ │ │ ├── passenger_island_c_0.png │ │ │ │ ├── passenger_island_c_1.png │ │ │ │ ├── passenger_island_d_0.png │ │ │ │ ├── passenger_island_d_1.png │ │ │ │ ├── passenger_island_e_0.png │ │ │ │ ├── passenger_island_e_1.png │ │ │ │ ├── passenger_island_f_0.png │ │ │ │ └── passenger_island_f_1.png │ │ │ └── water_textures │ │ │ │ ├── water_A.png │ │ │ │ ├── water_B.png │ │ │ │ ├── water_C.png │ │ │ │ └── water_D.png │ │ └── boats │ │ │ ├── ship_one.png │ │ │ ├── ship_two.png │ │ │ ├── plank_out.png │ │ │ ├── coal │ │ │ ├── coal_1.png │ │ │ ├── coal_2.png │ │ │ ├── coal_3.png │ │ │ ├── coal_4.png │ │ │ ├── coal_5.png │ │ │ └── coal_6.png │ │ │ ├── smoke_full_speed.png │ │ │ ├── smoke_half_speed.png │ │ │ ├── waves_full_speed.png │ │ │ ├── waves_half_speed.png │ │ │ └── passengers │ │ │ ├── passenger_a_ship_one.png │ │ │ ├── passenger_a_ship_two.png │ │ │ ├── passenger_b_ship_one.png │ │ │ └── passenger_b_ship_two.png │ ├── piranhas │ │ ├── grid.png │ │ ├── red_a.png │ │ ├── red_b.png │ │ ├── red_c.png │ │ ├── squid.png │ │ ├── blue_a.png │ │ ├── blue_b.png │ │ ├── blue_c.png │ │ ├── water_a.png │ │ ├── water_b.png │ │ ├── water_c.png │ │ └── grid-crop.png │ ├── fonts │ │ ├── Raleway-Bold.ttf │ │ ├── Raleway-Italic.ttf │ │ ├── Raleway-Regular.ttf │ │ └── Raleway-BoldItalic.ttf │ ├── logback.xml │ └── logback-test.xml │ └── kotlin │ └── sc │ └── gui │ ├── controller │ ├── client │ │ ├── ClientInterface.kt │ │ ├── ExternalClient.kt │ │ ├── GuiClient.kt │ │ └── ExecClient.kt │ ├── ServerController.kt │ ├── AppController.kt │ ├── ClientController.kt │ └── GameFlowController.kt │ ├── Events.kt │ ├── view │ ├── GameLoadingView.kt │ ├── game │ │ ├── PenguinsStyle.kt │ │ ├── PiranhasBoard.kt │ │ ├── PenguinsBoard.kt │ │ ├── HuIBoard.kt │ │ └── MississippiBoard.kt │ ├── PieceImage.kt │ ├── StatusView.kt │ ├── AppView.kt │ ├── GameView.kt │ ├── GameCreationView.kt │ └── ControlView.kt │ ├── util │ ├── FXUtils.kt │ ├── DesktopUtils.kt │ └── TidyFileAppender.kt │ ├── model │ ├── GameCreationModel.kt │ ├── AppModel.kt │ └── GameModel.kt │ ├── Constants.kt │ ├── GuiApp.kt │ ├── LobbyManager.kt │ └── AppStyle.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitattributes ├── .dev ├── scopes.txt └── githooks │ ├── commit-msg │ └── prepare-commit-msg ├── .gitmodules ├── settings.gradle.kts ├── .github ├── ISSUE_TEMPLATE │ ├── enhancement.yml │ └── bug-report.yml └── workflows │ └── gradle.yml ├── .gitignore ├── README.md ├── gradlew.bat ├── gradlew └── CHANGELOG.md /src/main/resources/strings_de_DE.properties: -------------------------------------------------------------------------------- 1 | color.red = Rot 2 | color.blue = Blau -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/sc.gui.view.GameBoard: -------------------------------------------------------------------------------- 1 | sc.gui.view.game.PiranhasBoard 2 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/sc.api.plugins.IGamePlugin: -------------------------------------------------------------------------------- 1 | sc.plugin2025.util.GamePlugin 2 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/sc.networking.XStreamProvider: -------------------------------------------------------------------------------- 1 | sc.plugin2025.util.XStreamClasses 2 | -------------------------------------------------------------------------------- /src/main/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/icon.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/hui/field_goal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_goal.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_hare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_hare.png -------------------------------------------------------------------------------- /src/main/resources/hui/icon_hare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/icon_hare.png -------------------------------------------------------------------------------- /src/main/resources/hui/player_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/player_red.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/goal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/goal.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/grid.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/red_a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/red_a.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/red_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/red_b.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/red_c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/red_c.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/squid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/squid.png -------------------------------------------------------------------------------- /src/main/resources/hui/card_eatsalad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/card_eatsalad.png -------------------------------------------------------------------------------- /src/main/resources/hui/card_fallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/card_fallback.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_blank.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_carrots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_carrots.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_market.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_market.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_salad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_salad.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_start.png -------------------------------------------------------------------------------- /src/main/resources/hui/icon_hedgehog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/icon_hedgehog.png -------------------------------------------------------------------------------- /src/main/resources/hui/player_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/player_blue.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/ship_one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/ship_one.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/ship_two.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/ship_two.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/logs_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/logs_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/logs_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/logs_B.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/blue_a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/blue_a.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/blue_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/blue_b.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/blue_c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/blue_c.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/water_a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/water_a.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/water_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/water_b.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/water_c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/water_c.png -------------------------------------------------------------------------------- /src/main/resources/fonts/Raleway-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/fonts/Raleway-Bold.ttf -------------------------------------------------------------------------------- /src/main/resources/hui/card_hurryahead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/card_hurryahead.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_hedgehog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_hedgehog.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/plank_out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/plank_out.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/stream_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/stream_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/stream_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/stream_B.png -------------------------------------------------------------------------------- /src/main/resources/piranhas/grid-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/piranhas/grid-crop.png -------------------------------------------------------------------------------- /src/main/resources/fonts/Raleway-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/fonts/Raleway-Italic.ttf -------------------------------------------------------------------------------- /src/main/resources/fonts/Raleway-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/fonts/Raleway-Regular.ttf -------------------------------------------------------------------------------- /src/main/resources/hui/card_swapcarrots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/card_swapcarrots.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_position_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_position_1.png -------------------------------------------------------------------------------- /src/main/resources/hui/field_position_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/field_position_2.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/coal/coal_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/coal/coal_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/coal/coal_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/coal/coal_2.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/coal/coal_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/coal/coal_3.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/coal/coal_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/coal/coal_4.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/coal/coal_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/coal/coal_5.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/coal/coal_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/coal/coal_6.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/sandbank_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/sandbank_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/sandbank_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/sandbank_B.png -------------------------------------------------------------------------------- /src/main/resources/fonts/Raleway-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/fonts/Raleway-BoldItalic.ttf -------------------------------------------------------------------------------- /src/main/resources/hui/card_exchangecarrots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/card_exchangecarrots.png -------------------------------------------------------------------------------- /src/main/resources/hui/background_very_simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/hui/background_very_simple.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/smoke_full_speed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/smoke_full_speed.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/smoke_half_speed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/smoke_half_speed.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/waves_full_speed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/waves_full_speed.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/waves_half_speed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/waves_half_speed.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_B.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_C.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_D.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_E.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_E.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_F.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_F.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_tile.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/background.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/empty_island_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/empty_island_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/empty_island_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/empty_island_B.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/empty_island_D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/empty_island_D.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/water_textures/water_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/water_textures/water_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/water_textures/water_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/water_textures/water_B.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/water_textures/water_C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/water_textures/water_C.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/water_textures/water_D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/water_textures/water_D.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/background_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/background_A.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/background_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/background_B.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/background_C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/background_C.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/background_D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/background_D.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/background_E.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/background_E.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/border_diagonal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/border_diagonal.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/border_vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/border_vertical.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_background.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_a_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_a_0.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_a_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_a_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_b_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_b_0.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_b_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_b_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_c_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_c_0.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_c_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_c_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_d_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_d_0.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_d_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_d_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_e_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_e_0.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_e_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_e_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_f_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_f_0.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/islands/passenger_island_f_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/islands/passenger_island_f_1.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/passengers/passenger_a_ship_one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/passengers/passenger_a_ship_one.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/passengers/passenger_a_ship_two.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/passengers/passenger_a_ship_two.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/passengers/passenger_b_ship_one.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/passengers/passenger_b_ship_one.png -------------------------------------------------------------------------------- /src/main/resources/mq/boats/passengers/passenger_b_ship_two.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/boats/passengers/passenger_b_ship_two.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/border_inner_corner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/border_inner_corner.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/border_outer_corner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/border_outer_corner.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_diagonal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_diagonal.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_vertical.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # These are explicitly windows files and should use crlf 5 | *.bat text eol=crlf 6 | 7 | -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_inner_corner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_inner_corner.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_outer_corner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_outer_corner.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_beach_diagonal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_beach_diagonal.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_beach_vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_beach_vertical.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_beach_inner_corner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_beach_inner_corner.png -------------------------------------------------------------------------------- /src/main/resources/mq/fields/background/fog_border_beach_outer_corner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-challenge/gui/HEAD/src/main/resources/mq/fields/background/fog_border_beach_outer_corner.png -------------------------------------------------------------------------------- /.dev/scopes.txt: -------------------------------------------------------------------------------- 1 | readme 2 | git 3 | gradle 4 | ci 5 | logs 6 | 7 | util 8 | ui 9 | view 10 | style 11 | 12 | model 13 | network 14 | controller 15 | clients 16 | 17 | game 18 | mq 19 | penguins 20 | hui 21 | piranhas 22 | hive 23 | blokus -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "backend"] 2 | path = backend 3 | url = https://github.com/software-challenge/backend 4 | shallow = true 5 | [submodule ".idea"] 6 | path = .idea 7 | url = https://github.com/software-challenge/idea-config 8 | shallow = true 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.4-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/client/ClientInterface.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller.client 2 | 3 | import sc.gui.model.PlayerType 4 | 5 | interface ClientInterface { 6 | val type: PlayerType 7 | fun joinGameRoom(roomId: String) 8 | fun joinGameWithReservation(reservation: String) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/Events.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.events 2 | 3 | import tornadofx.* 4 | 5 | sealed class GameControlEvent: FXEvent() 6 | data class PauseGame(val pause: Boolean): GameControlEvent() 7 | data class StepGame(val steps: Int): GameControlEvent() 8 | /** Signals that the current game should be terminated. 9 | * @param close whether to return to start screen */ 10 | data class TerminateGame(val close: Boolean = false): GameControlEvent() 11 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "software-challenge-gui" 2 | 3 | includeBuild("backend/gradle/custom-tasks") 4 | includeBuild("backend") { 5 | // https://publicobject.com/2021/03/11/includebuild 6 | dependencySubstitution { 7 | substitute(module("software-challenge:plugin2024")) 8 | .with(project(":plugin")) 9 | substitute(module("software-challenge:plugin2025")) 10 | .with(project(":plugin2025")) 11 | substitute(module("software-challenge:plugin")) 12 | .with(project(":plugin2026")) 13 | substitute(module("software-challenge:server")) 14 | .with(project(":server")) 15 | } 16 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: Verbesserungsvorschlag 2 | description: Etwas könnte besser laufen 3 | labels: ["enhancement"] 4 | assignees: 5 | - xeruf 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Danke, dass du dir Zeit nimmst, die Software-Challenge zu verbessern :) 11 | Bitte prüfe zuerst, dass du die [neuste Version](https://github.com/software-challenge/gui/releases) nutzt. 12 | - type: textarea 13 | attributes: 14 | label: Warum ist der aktuelle Stand nicht akzeptabel? 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Was wäre dein Lösungsansatz? 20 | validations: 21 | required: true -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/client/ExternalClient.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller.client 2 | 3 | import sc.gui.model.PlayerType 4 | 5 | class ExternalClient(private val host: String, private val port: Int) : ClientInterface { 6 | override val type = PlayerType.EXTERNAL 7 | 8 | override fun joinGameRoom(roomId: String) { 9 | println("Please start the manual client on $host:$port to join room $roomId") 10 | } 11 | 12 | override fun joinGameWithReservation(reservation: String) { 13 | throw NotImplementedError("External/Manual client can't join a prepared game (reservation: $reservation)") 14 | } 15 | 16 | override fun toString() = super.toString() + " on $host:$port" 17 | } 18 | -------------------------------------------------------------------------------- /.dev/githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | msg="$(head -1 "$1")" 3 | # Let git handle an empty commit message 4 | case "$msg" in (""|\#*) exit 0;; esac 5 | 6 | scopes_file=".dev/scopes.txt" 7 | # Join scopes to regex, skipping empty and commented lines 8 | test -r "$scopes_file" && 9 | scopes="($(grep -vE '^\s*(#|$)' "$scopes_file" | paste -sd '|' - ))" 10 | 11 | echo "$msg" | grep -Eq "^(fix|feat|enhance|docs|style|refactor|test|build|rework|release|revert)(\(${scopes:-.*}(/.+)?\))?: [A-Za-z]" 12 | result=$? 13 | if test $result -ne 0 14 | then printf "Invalid commit message: '$msg' 15 | Please check the guidelines at https://karma-runner.github.io/6.4/dev/git-commit-msg.html$(test -n "$scopes" && echo " with the scope one of $scopes as defined in $scopes_file")" >&2 16 | fi 17 | exit $result 18 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/GameLoadingView.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import javafx.geometry.Pos 4 | import sc.gui.AppStyle 5 | import sc.gui.serverAddress 6 | import sc.gui.serverPort 7 | import tornadofx.* 8 | import tornadofx.Stylesheet.Companion.legend 9 | 10 | class GameLoadingView: View() { 11 | override val root = vbox { 12 | alignment = Pos.CENTER 13 | 14 | vbox { 15 | alignment = Pos.TOP_CENTER 16 | label { 17 | addClass(AppStyle.big) 18 | text = "Das Spiel startet..." 19 | } 20 | label { 21 | addClass(legend) 22 | text = "Bitte verbinde gestartete Spieler auf $serverAddress:$serverPort" 23 | } 24 | } 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore generated directories 2 | .gradle/ 3 | build/ 4 | replays/ 5 | log/ 6 | 7 | # Ignore local gradle config 8 | gradle.properties 9 | 10 | SOCHA_BACKEND_DEPLOY_KEY* 11 | *.svg 12 | *spriter*/ 13 | **/keyframes/* 14 | !src/main/resources/graphics/cockle/keyframes/__olive_cockle* 15 | !src/main/resources/graphics/seagull/keyframes/__seagull* 16 | !src/main/resources/graphics/seal/keyframes/__cream_seal_idle_on_land_upright_0* 17 | !src/main/resources/graphics/seal/keyframes/__cream_seal_move* 18 | !src/main/resources/graphics/seal/keyframes/__cream_seal_transion* 19 | !src/main/resources/graphics/starfish/keyframes/__tan_starfish_side_view_happy_idle* 20 | !src/main/resources/graphics/starfish/keyframes/__tan_starfish_side_view_happy_move* 21 | !src/main/resources/graphics/starfish/keyframes/__tan_starfish_side_view_happy_jump* 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Fehlerbericht 2 | description: Etwas funktioniert nicht 3 | labels: ["bug"] 4 | assignees: 5 | - xeruf 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Danke, dass du dir Zeit nimmst, die Software-Challenge zu verbessern :) 11 | Bitte prüfe zuerst, dass du die [neuste Version](https://github.com/software-challenge/gui/releases) nutzt. 12 | - type: textarea 13 | attributes: 14 | label: Was ist passiert? 15 | description: Und was hast du eigentlich erwartet? 16 | validations: 17 | required: true 18 | - type: dropdown 19 | attributes: 20 | label: Dein Betriebssystem? 21 | options: 22 | - Windows 23 | - MacOS 24 | - Linux 25 | - Anderes 26 | - type: textarea 27 | attributes: 28 | label: Logs 29 | render: shell -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/ServerController.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller 2 | 3 | import ch.qos.logback.classic.LoggerContext 4 | import ch.qos.logback.core.util.StatusPrinter 5 | import org.slf4j.LoggerFactory 6 | import sc.server.Configuration 7 | import sc.server.Lobby 8 | import tornadofx.Controller 9 | 10 | class ServerController : Controller() { 11 | private val server = Lobby() 12 | 13 | fun startServer() { 14 | // output logback diagnostics to see if a logback.xml config was found 15 | val lc = LoggerFactory.getILoggerFactory() as LoggerContext 16 | StatusPrinter.print(lc) 17 | 18 | Configuration.loadServerProperties() 19 | Configuration.set(Configuration.SAVE_REPLAY, true) 20 | 21 | server.start() 22 | // TODO get address & port from server 23 | // TODO do we have to communicate via network at all? 24 | } 25 | 26 | fun stopServer() { 27 | server.close() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | ${LOG_DIRECTORY:-log}/game-server_${time}.log 8 | 20 9 | 5 10 | 11 | 12 | %magenta(%d{HH:mm:ss.SSS}) %highlight(%-5level %32([%.-30thread]) %36logger{36}) - %msg%n 13 | 14 | 15 | 16 | 17 | 18 | %magenta(%6relative) %highlight(%-5level %23([%.-21thread]) %34logger{34}) - %msg%n 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/util/FXUtils.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.util 2 | 3 | import javafx.beans.binding.Bindings 4 | import javafx.beans.binding.BooleanBinding 5 | import javafx.beans.binding.ObjectBinding 6 | import javafx.beans.value.ObservableValue 7 | import javafx.beans.value.WritableValue 8 | 9 | fun WritableValue.toggle() { 10 | value = !value 11 | } 12 | 13 | fun ObservableValue.listenImmediately(listener: (newValue: T) -> Unit) { 14 | listener(this.value) 15 | addListener { _, _, new -> listener(new) } 16 | } 17 | 18 | fun Array>.booleanBinding(listener: (values: List) -> Boolean): BooleanBinding = 19 | Bindings.createBooleanBinding({ listener(map { it.value }) }, *this) 20 | 21 | fun Array>.binding(listener: (values: List) -> U): ObjectBinding = 22 | Bindings.createObjectBinding({ listener(map { it.value }) }, *this) 23 | 24 | fun Array>.listen(listener: (values: List) -> Unit) { 25 | forEach { observable -> 26 | observable.addListener { _, _, _ -> 27 | listener(map { it.value }) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/util/DesktopUtils.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.util 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import tornadofx.* 5 | import java.awt.Desktop 6 | import java.io.File 7 | import java.io.IOException 8 | 9 | val logger = KotlinLogging.logger {} 10 | 11 | fun browseUrl(url: String) { 12 | FX.application.hostServices.showDocument(url) 13 | //URI(url).openDesktop(Desktop.Action.BROWSE, Desktop::browse) 14 | } 15 | 16 | fun browse(file: File) { 17 | logger.trace { "Browsing $file" } 18 | file.openDesktop(Desktop.Action.BROWSE_FILE_DIR, Desktop::browseFileDirectory) 19 | } 20 | 21 | fun T.openDesktop(action: Desktop.Action, open: Desktop.(T) -> Unit) { 22 | val desktop = Desktop.getDesktop() 23 | if(desktop.isSupported(action)) { 24 | logger.debug { "Opening $this on $desktop" } 25 | open(desktop, this) 26 | } else { 27 | logger.debug { "Opening $this with xdg-open" } 28 | try { 29 | ProcessBuilder("xdg-open", this.toString()).start() 30 | } catch(ex: IOException) { 31 | logger.warn(ex) { "Failed to open $this" } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | ${LOG_DIRECTORY:-log}/game-server_${time}.log 8 | 20 9 | 5 10 | 11 | 12 | %magenta(%d{HH:mm:ss.SSS}) %highlight(%-5level %32([%.-30thread]) %36logger{36}) - %msg%n 13 | 14 | 15 | 16 | 17 | 18 | %magenta(%6relative) %highlight(%-5level %23([%.-21thread]) %34logger{34}) - %msg%n 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/util/TidyFileAppender.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.util 2 | 3 | import ch.qos.logback.core.FileAppender 4 | import java.io.File 5 | 6 | /** Rotates files and automatically cleans up old ones. */ 7 | class TidyFileAppender: FileAppender() { 8 | /** Defaults to the parent dir of the current logfile. */ 9 | var directory: String? = null 10 | /** How many previous files to keep, by last modified attribute. */ 11 | var maxHistory: Int = 20 12 | /** Threshold for extra files that may be kept to reduce file system accesses. */ 13 | var threshold: Int = 0 14 | 15 | override fun start() { 16 | if (directory == null) 17 | directory = File(fileName).parent 18 | File(directory).list()?.let { files -> 19 | if (files.size > maxHistory + threshold) { 20 | files.map { File(directory, it) } 21 | .sortedBy { it.lastModified() } 22 | .take(files.size - maxHistory) 23 | //.also { println("Removing $it") } 24 | .forEach(File::delete) 25 | } 26 | } 27 | super.start() 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/model/GameCreationModel.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.model 2 | 3 | import javafx.beans.property.Property 4 | import sc.gui.humanEnabled 5 | import tornadofx.* 6 | import java.io.File 7 | 8 | enum class PlayerType(val description: String) { 9 | HUMAN("Mensch"), 10 | COMPUTER_SIMPLE("Zufalls-Computerspieler"), 11 | COMPUTER_ADVANCED("Fortgeschrittener Computerspieler"), 12 | COMPUTER("Eigener Computerspieler, von GUI gestartet"), 13 | EXTERNAL("Eigener Computerspieler, manuell gestartet"); 14 | override fun toString() = description 15 | companion object { 16 | /** Helper to disable human player until ready. */ 17 | fun allowedValues() = if(humanEnabled) entries else entries.takeLast(4) 18 | } 19 | } 20 | 21 | class TeamSettings(name: String? = "Team", type: PlayerType = PlayerType.HUMAN) { 22 | val name = objectProperty(name) 23 | val type = objectProperty(type) 24 | val executable = objectProperty() 25 | } 26 | 27 | class TeamSettingsModel(settings: TeamSettings): ItemViewModel(settings) { 28 | // explicit declarations needed - see https://github.com/edvin/tornadofx2/issues/12 29 | val name: Property = bind(TeamSettings::name) 30 | val type: Property = bind(TeamSettings::type) 31 | val executable: Property = bind(TeamSettings::executable) 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/Constants.kt: -------------------------------------------------------------------------------- 1 | package sc.gui 2 | 3 | import sc.server.network.NewClientListener 4 | import java.util.Locale 5 | import java.util.ResourceBundle 6 | 7 | // TODO automatically determine via ServiceLoader prop 8 | val humanEnabled = true 9 | val replaysEnabled = true 10 | 11 | val strings: ResourceBundle = ResourceBundle.getBundle("strings", Locale("de", "DE")) 12 | 13 | const val serverAddress = "localhost" 14 | 15 | val serverPort: Int 16 | get() = NewClientListener.lastUsedPort 17 | 18 | val guide = """ 19 | - Fahre über eine Figur, um ihre möglichen Züge zu sehen 20 | - Klicke eine Figur und dann das Zielfeld an, um sie zu bewegen 21 | - Durch ein erneutes Klicken auf die Figur kannst du sie wieder abwählen 22 | """.trimIndent() 23 | 24 | val guideMq = """ 25 | Bedienung von Mississippi Queen: 26 | - Das Label "S" an den Schiffen ist die aktuelle Geschwindigkeit 27 | - Das Label "M" am aktuellen Schiff sind die offenen Bewegungspunkte 28 | (Bei Bestätigung des Zuges wird aus diesen automatisch die nötige Beschleunigungsaktion berechnet) 29 | - Optional kann man am Beginn des Zuges manuell über die Knöpfe + und - beschleunigen. 30 | - Bewegungen menschlicher Spieler können über die Knöpfe oder die korrespondierenden Buchstabentasten erfolgen 31 | - Mit der Taste "S" wird der aktuelle Zug abgeschickt, mit "C" zurückgesetzt 32 | - Kohle wird automatisch abgezogen anhand der Regeln 33 | - Die Seite mit den Regeln lässt sich oben im Menü aufrufen 34 | Viel Spaß! 35 | """.trimIndent() -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/GuiApp.kt: -------------------------------------------------------------------------------- 1 | package sc.gui 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.stage.Stage 5 | import sc.gui.controller.ServerController 6 | import sc.gui.events.* 7 | import sc.gui.model.AppModel 8 | import sc.gui.view.AppView 9 | import sc.server.Configuration 10 | import sc.server.logbackFromPWD 11 | import tornadofx.* 12 | import kotlin.reflect.KClass 13 | 14 | open class ServerApp(primaryView: KClass) : App(primaryView, AppStyle::class) { 15 | private val server: ServerController by inject() 16 | override fun start(stage: Stage) { 17 | super.start(stage) 18 | 19 | try { 20 | Class.forName("com.tangorabox.componentinspector.fx.FXComponentInspectorHandler") 21 | .getDeclaredMethod("handleAll").invoke(null) 22 | dumpStylesheets() 23 | // reloading stylesheets breaks "Zug" font color in ControlView 24 | // reloadStylesheetsOnFocus() 25 | } catch(_: ClassNotFoundException) { 26 | } 27 | } 28 | 29 | override fun stop() { 30 | fire(TerminateGame()) 31 | AppModel.save() 32 | super.stop() 33 | server.stopServer() 34 | logger.info { "App stopped, Terminating" } 35 | } 36 | 37 | init { 38 | server.startServer() 39 | addStageIcon(resources.image("/icon.png")) 40 | } 41 | 42 | companion object { 43 | val logger = KotlinLogging.logger {} 44 | } 45 | } 46 | 47 | class GuiApp : ServerApp(AppView::class) 48 | 49 | fun main(args: Array) { 50 | logbackFromPWD() 51 | Configuration.setIfNotNull(Configuration.PORT_KEY, args.firstOrNull()?.toIntOrNull()?.toString()) 52 | launch(args) 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/client/GuiClient.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller.client 2 | 3 | import sc.api.plugins.IGameState 4 | import sc.api.plugins.IMove 5 | import sc.gui.model.PlayerType 6 | import sc.networking.clients.LobbyClient 7 | import sc.player.IGameHandler 8 | import sc.shared.GameResult 9 | import java.util.concurrent.CompletableFuture 10 | 11 | /** A client directly managed by the Gui. */ 12 | class GuiClient( 13 | host: String, 14 | port: Int, 15 | override val type: PlayerType, 16 | moveRequestHandler: (state: IGameState) -> CompletableFuture, 17 | ): ClientInterface { 18 | val player = LobbyClient(host, port).asPlayer(InternalGameHandler(moveRequestHandler)) 19 | 20 | override fun joinGameRoom(roomId: String) = player.joinGameRoom(roomId) 21 | override fun joinGameWithReservation(reservation: String) = player.joinGameWithReservation(reservation) 22 | 23 | private val location = "$host:$port" 24 | override fun toString() = super.toString() + " type $type on $location" 25 | } 26 | 27 | /** Handles communication with the server for a player whose moves are supplied by [moveRequestHandler]. */ 28 | class InternalGameHandler( 29 | private val moveRequestHandler: (state: IGameState) -> CompletableFuture, 30 | ): IGameHandler { 31 | private var currentState: IGameState? = null 32 | 33 | override fun calculateMove(): IMove = 34 | currentState?.let(moveRequestHandler)?.get() 35 | ?: throw IllegalStateException("Received move request before GameState!") 36 | 37 | override fun onUpdate(gameState: IGameState) { 38 | currentState = gameState 39 | } 40 | 41 | override fun onGameOver(data: GameResult) { 42 | } 43 | 44 | override fun onError(error: String) { 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/AppController.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import sc.gui.GameReadyEvent 5 | import sc.gui.events.* 6 | import sc.gui.model.AppModel 7 | import sc.gui.model.GameModel 8 | import sc.gui.model.ViewType 9 | import sc.gui.model.ViewType.* 10 | import sc.gui.util.toggle 11 | import sc.gui.view.GameCreationView 12 | import sc.gui.view.GameLoadingView 13 | import sc.gui.view.GameView 14 | import tornadofx.* 15 | 16 | object NavigateBackEvent: FXEvent() 17 | object CreateGame: FXEvent() 18 | 19 | class AppController: Controller() { 20 | val model = AppModel 21 | private val gameModel: GameModel by inject() 22 | private val clientController: ClientController by inject() 23 | 24 | init { 25 | subscribe { changeViewTo(GAME_CREATION) } 26 | subscribe { event -> 27 | changeViewTo(GAME_LOADING) 28 | gameModel.playerNames.setAll(event.settings.map { it.name.value }) 29 | task(daemon = true) { 30 | clientController.startGame(event.settings) 31 | } 32 | } 33 | subscribe { changeViewTo(GAME) } 34 | subscribe { if(it.close) changeViewTo(GAME_CREATION) } 35 | } 36 | 37 | private fun changeViewTo(newView: ViewType) { 38 | val current = model.currentView.get() 39 | logger.debug { "Requested View change from ${current.name} -> $newView" } 40 | if (current == newView) { 41 | logger.warn { "Noop view change request!" } 42 | return 43 | } 44 | find(current.view).replaceWith(newView.view) 45 | model.currentView.set(newView) 46 | } 47 | 48 | fun toggleDarkmode() { 49 | model.darkMode.toggle() 50 | } 51 | 52 | companion object { 53 | private val logger = KotlinLogging.logger {} 54 | } 55 | } 56 | 57 | val ViewType.view 58 | get() = when (this) { 59 | GAME_CREATION -> GameCreationView::class 60 | GAME_LOADING -> GameLoadingView::class 61 | GAME -> GameView::class 62 | } 63 | -------------------------------------------------------------------------------- /.dev/githooks/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This hook adds guidance about the commit message format 4 | # on top of the default commit message hints. 5 | # 6 | # Called by "git commit" with the name of the file that has the commit message, 7 | # followed by the description of the commit message's source. 8 | # 9 | # To enable this hook, set the hooksPath in git: 10 | # git config core.hooksPath .dev/githooks 11 | 12 | COMMIT_MSG_FILE=$1 13 | COMMIT_SOURCE=$2 14 | SHA1=$3 15 | 16 | scopes_file=".dev/scopes.txt" 17 | # Join scopes to regex, skipping empty and commented lines 18 | test -r "$scopes_file" && 19 | scopes="($(grep -vE '^\s*(#|$)' "$scopes_file" | paste -sd '|' - ))" 20 | 21 | beginswith() { case $2 in "$1"*) true;; *) false;; esac; } 22 | 23 | # https://mincong.io/2019/07/23/prepare-commit-message-using-git-hook 24 | # Only add custom message when there is no commit source ($COMMIT_SOURCE is empty). 25 | # Otherwise, keep the default message proposed by Git. 26 | # Possible commit sources: message, template, merge, squash or commit. 27 | # See https://git-scm.com/docs/githooks 28 | original=$(cat "$COMMIT_MSG_FILE") 29 | if test -z "$COMMIT_SOURCE" 30 | then 31 | # Find common path prefix of changed files 32 | path=$(while read file 33 | do test -z "$count" && common="$file" && count=$(expr length "$file") || 34 | while expr substr "$file" $count 1 != substr "$common" $count 1 >/dev/null; do let count--; done 35 | done <<<"$(git -P diff --cached --name-only -r)" && 36 | expr substr "$common" 1 "$count") 37 | { 38 | # Infer type & scope from changed files 39 | expr "$path" : ".dev" >/dev/null && 40 | ( git config list --local | grep -q kull && 41 | printf "feat(dev): " || 42 | printf "build: "; ) 43 | # Example for gradle projects 44 | expr "$path" : "gradle" \| "$path" : "build" >/dev/null && 45 | printf "build(gradle): " 46 | 47 | echo " 48 | # Please enter the message in the format: 49 | # (): 50 | # Types: 51 | # - production code: fix, feat, enhance, refactor, style 52 | # - auxiliaries: docs, test, build 53 | ${scopes:+# Allowed scopes: $scopes} 54 | # For details see https://kull.jfischer.org 55 | $original 56 | " 57 | } > "$COMMIT_MSG_FILE" 58 | fi 59 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/model/AppModel.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.model 2 | 3 | import javafx.beans.property.* 4 | import javafx.scene.Node 5 | import io.github.oshai.kotlinlogging.KotlinLogging 6 | import sc.gui.AppStyle 7 | import sc.gui.util.listenImmediately 8 | import tornadofx.* 9 | import java.util.prefs.Preferences 10 | 11 | enum class ViewType { 12 | GAME_CREATION, 13 | GAME_LOADING, 14 | GAME, 15 | } 16 | 17 | // TODO this shouldn't be global, only for GuiApp 18 | object AppModel: Component() { 19 | private val logger = KotlinLogging.logger { } 20 | 21 | val currentView = objectProperty(ViewType.GAME_CREATION) 22 | 23 | val darkMode = configurableBooleanProperty("dark", true) 24 | val animate = configurableBooleanProperty("animate", true) 25 | val scaling = configurableNumberProperty("scaling", 1) 26 | val decoratedWindow = configurableBooleanProperty("decoratedWindow", true) //System.getProperty("os.name").contains("mac")) 27 | 28 | fun save() { 29 | logger.debug { "Saving Preferences" } 30 | preferences { 31 | save(darkMode) 32 | save(animate) 33 | save(scaling) 34 | save(decoratedWindow) 35 | } 36 | } 37 | 38 | fun getTheme() = 39 | if(darkMode.value) 40 | AppStyle.Theme.DARK 41 | else 42 | AppStyle.Theme.LIGHT 43 | 44 | fun applyTheme(node: Node) = 45 | darkMode.listenImmediately { value -> 46 | if(value) { 47 | node.removeClass(AppStyle.lightColorSchema) 48 | node.addClass(AppStyle.darkColorSchema) 49 | } else { 50 | node.removeClass(AppStyle.darkColorSchema) 51 | node.addClass(AppStyle.lightColorSchema) 52 | } 53 | } 54 | } 55 | 56 | fun Component.configurableNumberProperty(key: String, default: Number): DoubleProperty { 57 | var value = default 58 | preferences { value = getDouble(key, default.toDouble()) } 59 | return SimpleDoubleProperty(this, key, value.toDouble()) 60 | } 61 | 62 | fun Component.configurableBooleanProperty(key: String, default: Boolean): BooleanProperty { 63 | var value = default 64 | preferences { value = getBoolean(key, default) } 65 | return SimpleBooleanProperty(this, key, value) 66 | } 67 | 68 | fun Preferences.save(prop: ReadOnlyBooleanProperty) = putBoolean(prop.name, prop.value) 69 | fun Preferences.save(prop: ReadOnlyDoubleProperty) = putDouble(prop.name, prop.value) 70 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/client/ExecClient.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller.client 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import sc.gui.model.PlayerType 6 | import java.io.File 7 | import java.io.OutputStream 8 | 9 | /** Represents a client started by the GUI from an executable. */ 10 | class ExecClient(val host: String, val port: Int, val clientExecutable: File): ClientInterface { 11 | override val type = PlayerType.COMPUTER 12 | 13 | override fun joinGameRoom(roomId: String) { 14 | startClient("--room", roomId) 15 | } 16 | 17 | override fun joinGameWithReservation(reservation: String) { 18 | startClient("--reservation", reservation) 19 | } 20 | 21 | private fun startClient(vararg options: String) { 22 | val command = mutableListOf( 23 | clientExecutable.absolutePath, 24 | "--host", host, 25 | "--port", port.toString(), 26 | *options 27 | ) 28 | if (clientExecutable.absolutePath.endsWith(".jar", true)) 29 | command.addAll(0, listOf( 30 | File(System.getProperty("java.home"), "bin").resolve("java") 31 | .takeIf { it.exists() }?.toString() ?: "java", 32 | "-jar")) 33 | logger.debug("Starting '${command.joinToString(" ")}'") 34 | val processBuilder = ProcessBuilder(command) 35 | val process = processBuilder.redirectErrorStream(true).start() 36 | 37 | Thread { 38 | process.inputStream.transferTo(LogOutputStream(logger)) 39 | } 40 | } 41 | 42 | override fun toString() = super.toString() + " on $host:$port running '$clientExecutable'" 43 | 44 | companion object { 45 | val logger: Logger = LoggerFactory.getLogger(ExecClient::class.java) 46 | } 47 | } 48 | 49 | /** This class logs all bytes written to it as output stream with a specified logging level. */ 50 | class LogOutputStream(val logger: Logger): OutputStream() { 51 | 52 | /** The internal memory for the written bytes. */ 53 | private var mem: String = "" 54 | 55 | override fun write(b: Int) { 56 | val bytes = ByteArray(1) 57 | bytes[0] = (b and 0xff).toByte() 58 | mem += bytes 59 | if (mem.endsWith("\n")) { 60 | mem = mem.substring(0, mem.length - 1) 61 | flush() 62 | } 63 | } 64 | 65 | override fun flush() { 66 | logger.info(mem) 67 | mem = "" 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request, create] 2 | jobs: 3 | build: 4 | runs-on: ${{ matrix.os }} 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs 9 | os: [ubuntu-latest, windows-latest, macos-latest] 10 | jdk: [11] 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | submodules: true 15 | fetch-depth: 9 16 | - name: Set up JDK ${{ matrix.jdk }} 17 | uses: actions/setup-java@v4 18 | with: 19 | distribution: 'temurin' 20 | java-version: ${{ matrix.jdk }} 21 | cache: 'gradle' 22 | - name: Grant execute permission for gradlew 23 | run: chmod +x gradlew 24 | - name: Execute tests 25 | run: ./gradlew check 26 | - name: Assemble fat jar 27 | run: ./gradlew shadowJar 28 | - name: Upload jar as artifact 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: software-challenge-gui-${{ github.sha }}-${{ matrix.os }} 32 | path: build/*.jar 33 | build-arm: 34 | runs-on: ${{ matrix.os }} 35 | if: startsWith(github.ref, 'refs/tags/') 36 | strategy: 37 | matrix: 38 | os: [macos-latest-large] 39 | jdk: [11] 40 | steps: 41 | - uses: actions/checkout@v4 42 | with: 43 | submodules: true 44 | fetch-depth: 2 45 | - name: Set up JDK ${{ matrix.jdk }} 46 | uses: actions/setup-java@v4 47 | with: 48 | distribution: 'temurin' 49 | java-version: ${{ matrix.jdk }} 50 | cache: 'gradle' 51 | - name: Grant execute permission for gradlew 52 | run: chmod +x gradlew 53 | - name: Assemble fat jar 54 | run: ./gradlew shadowJar 55 | - name: Upload jar as artifact 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: software-challenge-gui-${{ github.sha }}-${{ matrix.os }} 59 | path: build/*.jar 60 | release: 61 | needs: [build, build-arm] 62 | runs-on: ubuntu-latest 63 | if: startsWith(github.ref, 'refs/tags/') 64 | steps: 65 | - uses: actions/download-artifact@v4 # https://github.com/actions/download-artifact 66 | with: 67 | pattern: software-challenge-gui-${{ github.sha }}-* 68 | path: build 69 | merge-multiple: true 70 | - name: Release ${{ github.ref }} 71 | uses: softprops/action-gh-release@v1 # https://github.com/softprops/action-gh-release 72 | with: 73 | files: build/*.jar 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Software-Challenge Logo Grafischer Spieleserver der Software-Challenge Germany ![.github/workflows/gradle.yml](https://github.com/software-challenge/gui/workflows/.github/workflows/gradle.yml/badge.svg) 2 | 3 | Dies ist die Grafische Oberfläche für die Software-Challenge Germany, 4 | seit Saison 2020/21 in Kotlin TornadoFX aufbauend auf JavaFX. 5 | 6 | Nutzerdokumentation: https://docs.software-challenge.de/server.html 7 | 8 | > Hinweis: Wenn als erster Parameter des Programms eine Zahl mitgegeben wird, 9 | wird der Server auf diesem Port Verbindungen von Spielern erwarten. 10 | 11 | ## Für Entwickler 12 | 13 | ### Erste Schritte 14 | 15 | - zuerst das Projekt lokal mit Submodulen klonen: 16 | ```sh 17 | git clone https://github.com/software-challenge/gui.git --recurse-submodules --shallow-submodules 18 | ``` 19 | - mindestens Java 11 wird benötigt 20 | (ggf. `org.gradle.java.home=/path/to/jdk` in `gradle.properties` setzen) 21 | - `./gradlew run` ausführen 22 | 23 | ### Kollaboration 24 | 25 | Unsere Commit-Messages folgen dem Muster `type(scope): summary` 26 | (siehe [Karma Runner Konvention](http://karma-runner.github.io/6.2/dev/git-commit-msg.html)), 27 | wobei die gängigen Scopes in [.dev/scopes.txt](.dev/scopes.txt) definiert werden. 28 | Nach dem Klonen mit git sollte dazu der hook aktiviert werden: 29 | 30 | git config core.hooksPath .dev/githooks 31 | 32 | Um bei den Branches die Übersicht zu behalten, 33 | sollten diese ebenfalls nach der Konvention benannt werden, 34 | z. B. könnte ein Branch mit einem Release-Fix für Gradle `chore/gradle/release-fix` heißen 35 | und ein Branch, der ein neues Login-Feature zur GUI hinzufügt, `feat/gui-login`. 36 | 37 | Wenn die einzelnen Commits eines Pull Requests eigenständig funktionieren, 38 | sollte ein rebase merge durchgeführt werden, 39 | ansonsten (gerade bei experimentier-Branches) ein squash merge, 40 | wobei der Titel des Pull Requests der Commit-Message entsprechen sollte. 41 | 42 | Detaillierte Informationen zu unserem Kollaborations-Stil 43 | findet ihr in der [Kull Konvention](https://kull.jfischer.org). 44 | 45 | ### Java-Versionen und Abhängigkeiten 46 | 47 | Aktuell können die Backend-docs nur mit JDK 8 gebaut werden, 48 | dieses Projekt braucht jedoch für [tornadofx](https://github.com/edvin/tornadofx2) 49 | mindestens Java 11. 50 | Daher müssen die Releases separat gebaut werden. 51 | 52 | Tornadofx wird leider seit einigen Jahren nicht mehr entwickelt. 53 | Wir schauen gerade wie es da weitergeht. 54 | Eventuell ein eigener Fork. 55 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/model/GameModel.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.model 2 | 3 | import javafx.beans.value.ObservableValue 4 | import javafx.collections.FXCollections 5 | import javafx.collections.ObservableList 6 | import org.slf4j.LoggerFactory 7 | import sc.api.plugins.IGameState 8 | import sc.api.plugins.Team 9 | import sc.gui.GameOverEvent 10 | import sc.gui.controller.CreateGame 11 | import sc.gui.controller.HumanMoveAction 12 | import sc.gui.controller.HumanMoveRequest 13 | import sc.gui.events.* 14 | import sc.gui.util.booleanBinding 15 | import sc.shared.GameResult 16 | import tornadofx.* 17 | import kotlin.math.max 18 | 19 | class GameModel: ViewModel() { 20 | val playerNames: ObservableList = FXCollections.observableArrayList() 21 | val gameState = objectProperty(null) 22 | val gameResult = objectProperty() 23 | 24 | /** One step happens every 15 / x seconds. Defaults to 5. */ 25 | val stepSpeed = objectProperty(5.0) 26 | 27 | val currentTurn = integerBinding(gameState) { value?.turn ?: 0 } 28 | val currentTeam = nonNullObjectBinding(gameState) { value?.currentTeam ?: Team.ONE } 29 | 30 | val isHumanTurn = booleanProperty(false) 31 | 32 | val availableTurns = 33 | intProperty(0).apply { 34 | currentTurn.addListener { _, _, turn -> 35 | updateAvailableTurns(turn.toInt()) 36 | } 37 | } 38 | fun updateAvailableTurns(turn: Int) { 39 | availableTurns.set(max(turn, availableTurns.get())) 40 | } 41 | 42 | val atLatestTurn = 43 | arrayOf>(currentTurn, availableTurns) 44 | .booleanBinding { (cur, av) -> cur == av } 45 | 46 | val gameOver = gameResult.booleanBinding { it != null } 47 | val gameStarted = 48 | booleanBinding(availableTurns, isHumanTurn, gameOver) 49 | { value > 0 || isHumanTurn.value || gameOver.value } 50 | 51 | init { 52 | subscribe { 53 | runLater { 54 | isHumanTurn.set(true) 55 | } 56 | fire(PauseGame(false)) 57 | } 58 | subscribe { 59 | isHumanTurn.set(false) 60 | } 61 | subscribe { event -> 62 | gameResult.set(event.result) 63 | } 64 | subscribe { clearGame() } 65 | subscribe { clearGame() } 66 | } 67 | 68 | private fun clearGame() { 69 | logger.debug("Resetting GameModel") 70 | gameState.set(null) 71 | gameResult.set(null) 72 | availableTurns.set(0) 73 | isHumanTurn.set(false) 74 | } 75 | 76 | companion object { 77 | private val logger = LoggerFactory.getLogger(GameModel::class.java) 78 | } 79 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/game/PenguinsStyle.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view.game 2 | 3 | import javafx.geometry.Side 4 | import javafx.scene.layout.BackgroundPosition 5 | import javafx.scene.layout.BackgroundSize 6 | import sc.gui.AppStyle.Companion.background 7 | import tornadofx.* 8 | 9 | class PenguinsStyle: Stylesheet() { 10 | 11 | private val resources = ResourceLookup(this) 12 | 13 | private val colorBackground = c("#0ec9ff") 14 | 15 | init { 16 | background { 17 | backgroundImage += resources.url("/penguins/background.jpg").toURI() 18 | backgroundSize += BackgroundSize(BackgroundSize.AUTO, BackgroundSize.AUTO, false, false, true, false) 19 | backgroundPosition += BackgroundPosition(Side.LEFT, .0, true, Side.TOP, -10.0, false) 20 | } 21 | 22 | // Game 23 | arrayOf("fish", "ice").forEach { 24 | select(CssRule.c(it)) { image = resources.url("/penguins/$it.png").toURI() } 25 | } 26 | 27 | select(CssRule.c("penguin")) { 28 | focusColor = colorBackground 29 | val frames = PieceFrames("penguin", "penuin_3") { "jump_${it.padded}" } 30 | (0..19).forEach { frame -> 31 | and(CssRule.pc("idle$frame")) { 32 | javaClass.getResource(frames.getIdle(frame))?.toURI()?.let { image = it } 33 | } 34 | and(CssRule.pc("move$frame")) { 35 | javaClass.getResource(frames.getMove(frame))?.toURI()?.let { 36 | unsafe("-fx-image", raw(PropertyHolder.toCss(it) + " !important")) 37 | } 38 | } 39 | and(CssRule.pc("consume$frame")) { 40 | javaClass.getResource(frames.getConsume(frame))?.toURI()?.let { 41 | unsafe("-fx-image", raw(PropertyHolder.toCss(it) + " !important")) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | data class PieceFrames( 49 | val type: String, 50 | val prefix: String = type, 51 | val idlePrefix: String = "idle", 52 | private val move: ((Int) -> String) = { "walk_${it.padded}" }, 53 | private val consume: ((Int) -> String) = move, 54 | ) { 55 | fun getIdle(frame: Int) = getFrame("${idlePrefix}_${frame.padded}") 56 | fun getMove(frame: Int) = getFrame(move(frame)) 57 | fun getConsume(frame: Int) = getFrame(consume(frame)) 58 | private fun getFrame(suffix: String) = "/graphics/$type/__${prefix}_$suffix.png" 59 | } 60 | 61 | private fun chain(vararg frames: Pair): ((Int) -> String) = { frame -> 62 | var count = frame 63 | var index = -1 64 | var frameCount: Int 65 | do { 66 | index++ 67 | frameCount = frames[index].second 68 | val frameSub = frameCount.coerceAtLeast(1) 69 | if(count < frameSub || index == frames.lastIndex) 70 | break 71 | count -= frameSub 72 | } while(true) 73 | frames[index].first + if(frameCount < 1) "" else ("_" + count.coerceAtMost(frameCount - 1).padded) 74 | } 75 | } 76 | 77 | private val Int.padded 78 | get() = toString().padStart(3, '0') 79 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/PieceImage.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import javafx.animation.Animation 4 | import javafx.animation.KeyFrame 5 | import javafx.beans.value.ObservableDoubleValue 6 | import javafx.beans.value.ObservableValue 7 | import javafx.geometry.Pos 8 | import javafx.scene.image.ImageView 9 | import javafx.scene.layout.StackPane 10 | import javafx.util.Duration 11 | import sc.gui.model.AppModel 12 | import sc.gui.util.listenImmediately 13 | import tornadofx.* 14 | import java.lang.ref.WeakReference 15 | import kotlin.random.Random 16 | 17 | val animationDuration = Duration.seconds(0.1) 18 | val transitionDuration = animationDuration.multiply(8.0) 19 | val animationInterval = timeline { 20 | cycleCount = Animation.INDEFINITE 21 | this += KeyFrame(animationDuration, { 22 | animationFunctions.removeIf { 23 | // Remove invalid WeakReferences 24 | it.get()?.let { 25 | it() 26 | false 27 | } ?: true 28 | } 29 | }) 30 | } 31 | private val animationFunctions = ArrayList Unit>>() 32 | 33 | // this custom class is required to be able to shrink upsized images back to smaller sizes 34 | // see: https://stackoverflow.com/a/35202191/9127322 35 | class ResizableImageView(sizeProperty: ObservableValue): ImageView() { 36 | init { 37 | fitWidthProperty().bind(sizeProperty) 38 | isPreserveRatio = true 39 | } 40 | 41 | override fun minHeight(width: Double): Double = 16.0 42 | override fun minWidth(height: Double): Double = 16.0 43 | override fun isResizable(): Boolean = true 44 | 45 | override fun toString(): String = 46 | "${styleClass.joinToString(".")}@${Integer.toHexString(hashCode())}${pseudoClassStates.joinToString("") { ":$it" }}" 47 | } 48 | 49 | /** Holds a potentially animated piece on a position on the board. 50 | * Can stack multiple images and will resize automatically. */ 51 | class PieceImage(private val sizeProperty: ObservableDoubleValue, val content: String): StackPane() { 52 | private val animateFn = ::animate 53 | 54 | init { 55 | alignment = Pos.BOTTOM_CENTER 56 | addChild(content) 57 | // FIXME this creates race conditions 58 | //animationFunctions.add(WeakReference(animateFn)) 59 | viewOrder = 1.0 60 | } 61 | 62 | val finishFrames = false // true for Penguins, false for MQ 63 | val frameCount = 20 64 | var frame = Random.nextInt(1, frameCount) 65 | fun animate() { 66 | if((AppModel.animate.value || finishFrames && frame > 0) && !hasClass("inactive")) 67 | frame = nextFrame() 68 | } 69 | 70 | fun nextFrame( 71 | prefix: String = "idle", 72 | oldFrame: Int = frame, 73 | randomize: Boolean = true, 74 | remove: Boolean = false, 75 | ): Int { 76 | val img = children.lastOrNull() 77 | img?.removePseudoClass("$prefix$oldFrame") 78 | return if(!remove) 79 | (oldFrame.inc() + if(randomize) Random.nextInt(1, 5).div(5) else 0) 80 | .mod(frameCount).also { newFrame -> 81 | img?.addPseudoClass("$prefix$newFrame") 82 | } 83 | else -1 84 | } 85 | 86 | fun addChild(graphic: String, index: Int? = null) { 87 | //logger.trace { "$this: Adding $graphic" } 88 | children.add(index ?: children.size, ResizableImageView(sizeProperty).apply { 89 | addClass(graphic) 90 | if(graphic == "penguin") 91 | sizeProperty.listenImmediately { 92 | this.translateY = -it.toDouble() / 5 93 | } 94 | }) 95 | } 96 | 97 | override fun toString(): String = 98 | "$content@${Integer.toHexString(hashCode())}" + 99 | pseudoClassStates.joinToString("") { ":$it" } + 100 | children 101 | 102 | } 103 | 104 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/ClientController.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller 2 | 3 | import sc.api.plugins.IGameState 4 | import sc.api.plugins.IMove 5 | import sc.api.plugins.SENSIBLE_MOVES_COUNT 6 | import sc.api.plugins.TwoPlayerGameState 7 | import sc.api.plugins.exceptions.GameLogicException 8 | import sc.gui.LobbyManager 9 | import sc.gui.controller.client.ClientInterface 10 | import sc.gui.controller.client.ExecClient 11 | import sc.gui.controller.client.ExternalClient 12 | import sc.gui.controller.client.GuiClient 13 | import sc.gui.model.PlayerType 14 | import sc.gui.model.TeamSettings 15 | import sc.gui.serverAddress 16 | import sc.gui.serverPort 17 | import tornadofx.* 18 | import java.util.concurrent.CompletableFuture 19 | import kotlin.random.Random 20 | 21 | data class StartGame(val settings: List): FXEvent() 22 | class HumanMoveRequest: FXEvent() 23 | /** Human making a move. 24 | * @param move the move */ 25 | data class HumanMoveAction(val move: IMove): FXEvent() 26 | 27 | data class Player(val name: String, val client: ClientInterface) 28 | 29 | class ClientController: Controller() { 30 | private val host = serverAddress 31 | private val port = serverPort 32 | private val lobbyManager = LobbyManager(host, port) 33 | 34 | // Do NOT call this directly in the UI thread, use fire(StartGameRequest(gameCreationModel)) 35 | // This way, the game starting is done in the background - otherwise the UI will be blocked 36 | // TODO put everything triggered by events in a different class and call these from the controller using events 37 | fun startGame(playerSettings: List) { 38 | val players = playerSettings.map { teamSettings -> 39 | Player(teamSettings.name.get(), when (val type = teamSettings.type.value) { 40 | PlayerType.HUMAN -> GuiClient(host, port, type, ::humanMoveRequest) 41 | PlayerType.COMPUTER_SIMPLE -> GuiClient(host, port, type, ::getSimpleMove) 42 | PlayerType.COMPUTER_ADVANCED -> GuiClient(host, port, type, ::getAdvancedMove) 43 | PlayerType.COMPUTER -> ExecClient(host, port, teamSettings.executable.get()) 44 | PlayerType.EXTERNAL -> ExternalClient(host, port) 45 | }) 46 | } 47 | // TODO handle client start failures 48 | 49 | lobbyManager.startNewGame(players, players.none { it.client.type == PlayerType.HUMAN }) 50 | } 51 | 52 | fun humanMoveRequest(@Suppress("UNUSED_PARAMETER") state: IGameState): CompletableFuture { 53 | val future = CompletableFuture() 54 | subscribe(1) { 55 | future.complete(it.move) 56 | } 57 | fire(HumanMoveRequest()) 58 | return future 59 | } 60 | 61 | val random = Random 62 | 63 | /** Reservoir sampling. 64 | * https://math.stackexchange.com/questions/1058500/can-you-select-random-entry-from-unknown-number-of-entries/1058547#1058547 */ 65 | fun getSimpleMove(state: IGameState): CompletableFuture { 66 | val possibleMoves = state.moveIterator() 67 | var selection = possibleMoves.next() 68 | var count = 1 69 | while(possibleMoves.hasNext()) { 70 | count++ 71 | val next = possibleMoves.next() 72 | if(random.nextInt(count) == 0) 73 | selection = next 74 | } 75 | return CompletableFuture.completedFuture(selection) 76 | } 77 | 78 | /** Evaluation of following state. */ 79 | fun getAdvancedMove(state: IGameState): CompletableFuture { 80 | val possibleMoves = state.moveIterator() 81 | if (!possibleMoves.hasNext()) 82 | throw GameLogicException("No possible Moves found!") 83 | val best = ArrayList() 84 | var bestValue = Integer.MIN_VALUE 85 | var count = 0 86 | while(possibleMoves.hasNext() && count < SENSIBLE_MOVES_COUNT) { 87 | val next = possibleMoves.next() 88 | @Suppress("UNCHECKED_CAST") 89 | val newState = (state as TwoPlayerGameState).performMove(next) 90 | val points = newState.getPointsForTeamExtended(state.currentTeam).sum() - 91 | newState.getPointsForTeamExtended(state.otherTeam).sum() 92 | if(points >= bestValue) { 93 | if(points > bestValue) 94 | best.clear() 95 | best.add(next) 96 | bestValue = points 97 | } 98 | count++ 99 | } 100 | return CompletableFuture.completedFuture(best.random()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/StatusView.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import javafx.beans.binding.StringBinding 4 | import javafx.geometry.Pos 5 | import javafx.scene.control.Label 6 | import javafx.scene.layout.Priority 7 | import javafx.scene.paint.Color 8 | import javafx.scene.text.TextAlignment 9 | import sc.api.plugins.ITeam 10 | import sc.api.plugins.Team 11 | import sc.gui.AppStyle 12 | import sc.gui.model.AppModel 13 | import sc.gui.model.GameModel 14 | import sc.gui.strings 15 | import tornadofx.* 16 | 17 | class StatusBinding(private val game: GameModel): StringBinding() { 18 | init { 19 | bind(game.gameStarted, game.currentTeam, game.gameResult, game.playerNames, game.atLatestTurn) 20 | } 21 | 22 | override fun computeValue(): String = 23 | if(game.gameStarted.value && game.atLatestTurn.value || game.gameResult.value != null) 24 | game.gameResult.takeIf { game.atLatestTurn.value }?.get()?.let { gameResult -> 25 | """ 26 | ${gameResult.win?.winner?.let { "${it.displayName} hat gewonnen!" } ?: "Unentschieden"} 27 | ${gameResult.win?.reason?.message?.replace(" brig", " übrig").orEmpty()} 28 | """.trimIndent().trim('\n') 29 | } ?: "${game.currentTeam.value.displayName} am Zug" 30 | else game.playerNames.joinToString(" vs ") 31 | 32 | val ITeam.displayName 33 | get() = index.let { game.playerNames.getOrNull(it) ?: "Spieler ${it + 1}" } 34 | } 35 | 36 | class ScoreBinding(private val game: GameModel): StringBinding() { 37 | init { 38 | bind(game.gameStarted, game.gameState) 39 | } 40 | 41 | override fun computeValue(): String = 42 | if(game.gameStarted.value) 43 | "Runde ${(game.currentTurn.get() + 1) / 2} - " + 44 | game.gameState.value?.run { 45 | Team.values().sortedBy { it != startTeam }.joinToString(" : ") { 46 | getPointsForTeam(it).first().toString() 47 | } 48 | } 49 | else "Drücke auf Start".takeUnless { game.gameOver.value && game.atLatestTurn.value }.orEmpty() 50 | } 51 | 52 | class StatusView: View() { 53 | private val game: GameModel by inject() 54 | 55 | override val root = hbox { 56 | useMaxWidth = true 57 | alignment = Pos.CENTER 58 | add(playerLabel(Team.ONE)) 59 | vbox(alignment = Pos.CENTER) { 60 | this.spacing = AppStyle.fontSizeUnscaled.value 61 | runLater { 62 | prefWidthProperty().bind(scene.widthProperty().divide(2)) 63 | hgrow = Priority.ALWAYS 64 | maxWidth = AppStyle.fontSizeRegular.value * 60 65 | } 66 | addClass(AppStyle.statusLabel) 67 | label(StatusBinding(game)) { 68 | textAlignment = TextAlignment.CENTER 69 | isWrapText = true 70 | } 71 | label(ScoreBinding(game)) 72 | } 73 | add(playerLabel(Team.TWO)) 74 | 75 | //runLater { 76 | // scene.root.apply { 77 | // add(Label().apply { 78 | // textProperty().bind(playerLabel(Team.ONE)) 79 | // stackpaneConstraints { 80 | // alignment = Pos.TOP_LEFT 81 | // } 82 | // }) 83 | // add(Label().apply { 84 | // textProperty().bind(playerLabel(Team.TWO)) 85 | // stackpaneConstraints { 86 | // alignment = Pos.TOP_RIGHT 87 | // } 88 | // }) 89 | // } 90 | //} 91 | } 92 | 93 | fun playerLabel(team: Team) = 94 | Label().apply { 95 | textProperty().bind( 96 | game.gameState.stringBinding { state -> 97 | state?.teamStats(team)?.takeUnless { it.isEmpty() }?.let { stats -> 98 | stats.joinToString( 99 | "\n", 100 | "${game.playerNames[team.index]} (${strings["color.${team.color}"]})\n" 101 | ) { stat -> 102 | "${stat.label} ${stat.icon?.let { if(stat.value > 0) it.repeat(stat.value) else "-" } ?: stat.value}" 103 | } 104 | } 105 | }) 106 | textFillProperty().bind(AppModel.darkMode.objectBinding { 107 | if(it == true) 108 | Color.hsb(Color.valueOf(team.color).hue, .4, 1.0) 109 | else 110 | Color.hsb(Color.valueOf(team.color).hue, .8, .6) 111 | }) 112 | } 113 | } -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/AppView.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.application.Platform 5 | import javafx.scene.control.Alert 6 | import sc.api.plugins.IGamePlugin 7 | import sc.gui.AppStyle 8 | import sc.gui.controller.AppController 9 | import sc.gui.controller.CreateGame 10 | import sc.gui.controller.selectReplay 11 | import sc.gui.events.* 12 | import sc.gui.guide 13 | import sc.gui.model.ViewType 14 | import sc.gui.replaysEnabled 15 | import sc.gui.util.browse 16 | import sc.gui.util.browseUrl 17 | import tornadofx.* 18 | import java.io.File 19 | 20 | private val logger = KotlinLogging.logger {} 21 | 22 | class AppView: View("Software-Challenge Germany") { 23 | private val controller: AppController by inject() 24 | private val sochaIcon = resources.imageview("/icon.png") 25 | 26 | override val root = borderpane { 27 | addClass(AppStyle.lightColorSchema) 28 | top = menubar { 29 | // TODO help menus keep disappearing and is offset 30 | menu(graphic = sochaIcon) { 31 | item("Beenden", "Shortcut+Q").action { 32 | logger.debug("Quitting!") 33 | Platform.exit() 34 | } 35 | item("Neues Spiel", "Shortcut+N") { 36 | enableWhen(controller.model.currentView.isNotEqualTo(ViewType.GAME_CREATION)) 37 | action { 38 | logger.debug("New Game!") 39 | if(controller.model.currentView.get() == ViewType.GAME) { 40 | confirm( 41 | header = "Neues Spiel anfangen", 42 | content = "Willst du wirklich dein aktuelles Spiel verwerfen und ein neues anfangen?", 43 | ) { fire(TerminateGame(true)) } 44 | } else { 45 | fire(CreateGame) 46 | } 47 | } 48 | } 49 | item("Dunkles Design umschalten", "Shortcut+U").action { 50 | controller.toggleDarkmode() 51 | } 52 | separator() 53 | if(replaysEnabled) 54 | item("Replay laden", "Shortcut+R").action { 55 | selectReplay { 56 | if(controller.model.currentView.get() == ViewType.GAME) 57 | fire(TerminateGame()) 58 | } 59 | } 60 | item("Logs öffnen", "Shortcut+L").action { 61 | browse(File("log").absoluteFile) 62 | } 63 | } 64 | menu("Hilfe") { 65 | viewOrder = -9.0 66 | item("Bedienhilfe", "Shortcut+H").action { 67 | alert(Alert.AlertType.INFORMATION, 68 | header = "Bedienhilfe", 69 | content = guide, 70 | title = "Hilfe") 71 | } 72 | item("↗ Spielregeln", "Shortcut+S").action { 73 | browseUrl("https://docs.software-challenge.de/spiele/aktuell/regeln") 74 | } 75 | item("↗ Dokumentation", "Shortcut+D").action { 76 | browseUrl("https://docs.software-challenge.de") 77 | } 78 | item("↗ Webseite", "Shortcut+I").action { 79 | browseUrl("https://www.software-challenge.de") 80 | } 81 | item("↗ Wettbewerb", "Shortcut+W").action { 82 | browseUrl("https://contest.software-challenge.de") 83 | } 84 | } 85 | } 86 | } 87 | 88 | init { 89 | sochaIcon.fitHeight = 32.0 90 | sochaIcon.fitWidth = 32.0 91 | with(root) { 92 | prefWidth = 1100.0 93 | prefHeight = 700.0 94 | center = AppStyle.background().apply { add(GameCreationView::class) } 95 | fire(CreateGame) 96 | // DEBUG Platform.runLater { scene.addEventHandler(EventType.ROOT) { logger.trace("EVENT: {}", it) } } 97 | } 98 | 99 | val gameTitle = IGamePlugin.loadPlugin().name 100 | val version = resources.text("/version.txt") 101 | val sochaTitle = "Software-Challenge GUI $version" 102 | titleProperty.bind(controller.model.currentView.stringBinding { 103 | when(it) { 104 | ViewType.GAME_CREATION -> sochaTitle 105 | ViewType.GAME_LOADING -> "Spiel Startet - $sochaTitle" 106 | ViewType.GAME -> "Spiele $gameTitle - $sochaTitle" 107 | null -> throw NoWhenBranchMatchedException("Current view can't be null!") 108 | }.also { logger.debug { "New window title: $it" } } 109 | }) 110 | 111 | controller.model.applyTheme(root) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/LobbyManager.kt: -------------------------------------------------------------------------------- 1 | package sc.gui 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.application.Platform 5 | import sc.api.plugins.IGamePlugin 6 | import sc.api.plugins.IGameState 7 | import sc.gui.controller.GameFlowController 8 | import sc.gui.controller.Player 9 | import sc.gui.controller.client.ClientInterface 10 | import sc.gui.model.PlayerType 11 | import sc.networking.clients.AdminClient 12 | import sc.networking.clients.LobbyClient 13 | import sc.protocol.ResponsePacket 14 | import sc.protocol.requests.PrepareGameRequest 15 | import sc.protocol.responses.ErrorPacket 16 | import sc.protocol.responses.GamePreparedResponse 17 | import sc.protocol.room.ErrorMessage 18 | import sc.protocol.room.GamePaused 19 | import sc.protocol.room.MementoMessage 20 | import sc.server.Configuration 21 | import sc.shared.GameResult 22 | import sc.shared.SlotDescriptor 23 | import tornadofx.* 24 | import java.net.ConnectException 25 | import java.util.ArrayDeque 26 | import java.util.Queue 27 | import java.util.function.Consumer 28 | import kotlin.system.exitProcess 29 | 30 | sealed class GameUpdateEvent: FXEvent() 31 | class GameReadyEvent: GameUpdateEvent() 32 | data class NewGameState(val gameState: IGameState): GameUpdateEvent() 33 | data class GamePausedEvent(val paused: Boolean): GameUpdateEvent() 34 | data class GameOverEvent(val result: GameResult): GameUpdateEvent() 35 | 36 | class LobbyManager(host: String, port: Int): Controller(), Consumer { 37 | private val pendingPlayers: Queue = ArrayDeque() 38 | 39 | private val client: AdminClient = try { 40 | LobbyClient(host, port).authenticate(Configuration.get(Configuration.PASSWORD_KEY), this) 41 | } catch (e: ConnectException) { 42 | logger.error(e) { "Could not connect to server: " + e.message } 43 | exitProcess(1) 44 | } 45 | 46 | override fun accept(packet: ResponsePacket) { 47 | when (packet) { 48 | is GamePreparedResponse -> { 49 | enterRoom(packet.roomId) 50 | var reservationIndex = 0 51 | pendingPlayers.removeAll { player -> 52 | if (player.type == PlayerType.EXTERNAL) { 53 | player.joinGameRoom(packet.roomId) 54 | } else { 55 | if (reservationIndex >= packet.reservations.size) { 56 | logger.warn { "More players than reservations, left with $pendingPlayers" } 57 | return@removeAll false 58 | } 59 | player.joinGameWithReservation(packet.reservations[reservationIndex++]) 60 | } 61 | true 62 | } 63 | } 64 | is ErrorPacket -> { 65 | logger.error { "$packet" } 66 | // if (packet.originalRequest !is CancelRequest) 67 | Platform.runLater { 68 | error("Fehler in der Kommunikation mit dem Server", packet.toString()).setOnCloseRequest { 69 | // TODO don't close connection from server 70 | Runtime.getRuntime().halt(1) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | private val gameFlowController by inject() 78 | 79 | /** Take over the prepared room and start observing. */ 80 | private fun enterRoom(roomId: String) { 81 | gameFlowController.controller = client.control(roomId) 82 | subscribe(1) { fire(GameReadyEvent()) } 83 | client.observe(roomId) { msg -> 84 | logger.trace { "New RoomMessage in $roomId: $msg" } 85 | when(msg) { 86 | is MementoMessage -> fire(NewGameState(msg.state)) 87 | is GameResult -> if(gameFlowController.controller != null) fire(GameOverEvent(msg)) 88 | is ErrorMessage -> logger.warn { "Error in $roomId: $msg" } 89 | is GamePaused -> fire(GamePausedEvent(msg.paused)) 90 | } 91 | } 92 | } 93 | 94 | fun startNewGame(players: List, paused: Boolean) { 95 | val gameId = IGamePlugin.loadPlugin().id 96 | logger.trace { "Available game plugins: " + IGamePlugin.loadPlugins().asSequence().sortedByDescending { it.id }.map { it.id }.joinToString(", ") } 97 | logger.debug { "Starting new game of $gameId (paused: $paused, players: $players)" } 98 | pendingPlayers.addAll(players.map { it.client }) 99 | 100 | client.prepareGame(PrepareGameRequest( 101 | gameId, 102 | players.map { 103 | SlotDescriptor(it.name, 104 | it.client.type != PlayerType.HUMAN, 105 | it.client.type != PlayerType.EXTERNAL) 106 | }.toTypedArray(), 107 | paused)) 108 | } 109 | 110 | companion object { 111 | private val logger = KotlinLogging.logger {} 112 | } 113 | } -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/GameView.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.application.Platform 5 | import javafx.beans.binding.Bindings 6 | import javafx.beans.value.ChangeListener 7 | import javafx.beans.value.ObservableValue 8 | import javafx.scene.Node 9 | import javafx.scene.effect.Glow 10 | import javafx.scene.input.KeyEvent 11 | import javafx.scene.layout.Region 12 | import javafx.util.Duration 13 | import sc.api.plugins.IGameState 14 | import sc.api.plugins.IMove 15 | import sc.gui.AppStyle 16 | import sc.gui.controller.HumanMoveAction 17 | import sc.gui.model.GameModel 18 | import tornadofx.* 19 | import java.util.ServiceLoader 20 | 21 | class GameView: View() { 22 | override val root = borderpane { 23 | paddingAll = AppStyle.spacing 24 | top(StatusView::class) 25 | // TODO allow selection? Move boards into plugins? 26 | center = ServiceLoader.load(GameBoard::class.java).findFirst().get().root 27 | bottom(ControlView::class) 28 | } 29 | } 30 | 31 | @Suppress("UNCHECKED_CAST") 32 | abstract class GameBoard: View(), ChangeListener { 33 | protected val logger = KotlinLogging.logger { } 34 | 35 | protected val gameModel: GameModel by inject() 36 | protected val gameState: GameState? 37 | get() = gameModel.gameState.value as? GameState 38 | 39 | protected val awaitingHumanMove = 40 | gameModel.isHumanTurn.booleanBinding(gameModel.atLatestTurn) { 41 | (gameModel.atLatestTurn.value && gameModel.isHumanTurn.value).also { 42 | logger.trace { "Awaiting Human Turn: $it" } 43 | } 44 | } 45 | 46 | override fun changed(observable: ObservableValue?, oldValue: IGameState?, newValue: IGameState?) { 47 | onNewState(oldValue as? GameState, newValue as? GameState) 48 | } 49 | 50 | abstract fun onNewState(oldState: GameState?, state: GameState?) 51 | 52 | abstract override val root: Region 53 | protected val viewHeight: Double 54 | get() = (root.parent as? Region ?: root).height 55 | .coerceAtMost(root.scene?.height?.minus(AppStyle.fontSizeBig.value * 4 + AppStyle.fontSizeUnscaled.value * 10) ?: Double.MAX_VALUE) 56 | /** Length of the smaller side of the window. */ 57 | protected val squareSize = doubleProperty(16.0) 58 | 59 | /** Shorter animations when game speed is higher. 60 | * Animations should be finished within 2 times this value. */ 61 | protected val animFactor 62 | get() = 3 / gameModel.stepSpeed.value 63 | 64 | protected val contrastFactor = 0.5 65 | protected fun Node.glow(factor: Number = 1) { 66 | val glow: Glow = effect.let { 67 | it as? Glow ?: Glow().also { effect = it } 68 | } 69 | timeline { 70 | keyframe(Duration.ZERO) { 71 | keyvalue( 72 | glow.levelProperty(), 73 | glow.level 74 | ) 75 | } 76 | keyframe(Duration.seconds(animFactor / 2)) { 77 | keyvalue( 78 | glow.levelProperty(), 79 | contrastFactor * factor.toDouble() 80 | ) 81 | } 82 | } 83 | } 84 | 85 | init { 86 | Platform.runLater { 87 | squareSize.bind(Bindings.min(root.widthProperty(), root.heightProperty().doubleBinding { viewHeight })) 88 | gameModel.gameState.addListener(this) 89 | this.changed(null, null, gameModel.gameState.value) 90 | } 91 | 92 | Platform.runLater { 93 | awaitingHumanMove.addListener { _ -> 94 | Platform.runLater { 95 | checkHumanControls() 96 | } 97 | } 98 | 99 | root.scene.setOnKeyPressed { keyEvent -> 100 | val state = gameState ?: return@setOnKeyPressed 101 | if(handleKeyPress(state, keyEvent)) { 102 | keyEvent.consume() 103 | } 104 | } 105 | } 106 | } 107 | 108 | /** For keyboard based controls - accessibility! */ 109 | protected abstract fun handleKeyPress(state: GameState, keyEvent: KeyEvent): Boolean 110 | 111 | /** Show human controls if it is a human move. */ 112 | protected fun checkHumanControls() { 113 | if(awaitingHumanMove.value) 114 | renderHumanControls(gameState ?: return) 115 | } 116 | 117 | /** Called when a human player needs to make a move to render needed UI elements. */ 118 | protected abstract fun renderHumanControls(state: GameState) 119 | 120 | /** Send move if it is humans turn. */ 121 | protected fun sendHumanMove(move: IMove): Boolean { 122 | if(awaitingHumanMove.value) { 123 | fire(HumanMoveAction(move.also { 124 | logger.debug { "Human Move: $it" } 125 | })) 126 | return true 127 | } 128 | return false 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/controller/GameFlowController.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.controller 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.animation.Animation 5 | import javafx.animation.KeyFrame 6 | import javafx.animation.Timeline 7 | import javafx.stage.FileChooser 8 | import javafx.util.Duration 9 | import sc.api.plugins.IGameState 10 | import sc.framework.HelperMethods 11 | import sc.framework.ReplayLoader 12 | import sc.gui.GamePausedEvent 13 | import sc.gui.GameReadyEvent 14 | import sc.gui.NewGameState 15 | import sc.gui.events.* 16 | import sc.gui.model.GameModel 17 | import sc.networking.clients.IGameController 18 | import tornadofx.* 19 | import java.io.IOException 20 | 21 | fun View.selectReplay(onConfirm: () -> Unit = {}) { 22 | chooseFile( 23 | "Replay laden", 24 | arrayOf(FileChooser.ExtensionFilter("XML", "*.xml", "*.xml.gz")), 25 | HelperMethods.replayFolder.takeIf { it.exists() }, 26 | mode = FileChooserMode.Single, 27 | ).forEach { 28 | onConfirm() 29 | try { 30 | find(GameFlowController::class).loadReplay(ReplayLoader(it)) 31 | } catch(e: Exception) { 32 | warning("Replay laden fehlgeschlagen", "Das Replay $it konnte nicht geladen werden:\n" + e.stackTraceToString()) 33 | } 34 | } 35 | } 36 | 37 | class GameFlowController: Controller() { 38 | private val logger = KotlinLogging.logger {} 39 | 40 | private val gameModel: GameModel by inject() 41 | /** Whether to request a new Move when stepping forward. */ 42 | private var stepController = true 43 | private val stepInterval = Timeline(KeyFrame(Duration.seconds(gameModel.stepSpeed.value * 3), { 44 | logger.trace { "Firing Stepgame" } 45 | fire(StepGame(1)) 46 | })).apply { 47 | cycleCount = Animation.INDEFINITE 48 | rateProperty().bind(gameModel.stepSpeed) 49 | } 50 | 51 | private val history = ArrayList() 52 | /** Used to control running Game - is null for completed Game/Replay. */ 53 | var controller: IGameController? = null 54 | 55 | init { 56 | subscribe { event -> 57 | controller?.let { 58 | it.pause(event.pause) 59 | stepController = false 60 | } ?: fire(GamePausedEvent(event.pause)) 61 | if(event.pause) { 62 | stepInterval.pause() 63 | } else { 64 | stepInterval.play() 65 | } 66 | } 67 | subscribe { event -> 68 | logger.debug { "Received $event" } 69 | val turn = gameModel.currentTurn.value + event.steps 70 | val state: IGameState? = 71 | if(event.steps > 0) 72 | history.firstOrNull { it.turn >= turn } ?: run { 73 | if(stepController) { 74 | logger.trace { "Requesting next turn" } 75 | controller?.step() 76 | history.lastOrNull() 77 | } else { 78 | logger.trace { "Pausing Stepping" } 79 | stepInterval.pause() 80 | stepController = true 81 | null 82 | } 83 | } 84 | else 85 | history.lastOrNull { it.turn <= turn } ?: history.first() 86 | if(state != null) 87 | gameModel.gameState.set(state) 88 | } 89 | gameModel.gameOver.onChange { 90 | if(it) 91 | controller = null 92 | } 93 | subscribe { 94 | stepInterval.pause() 95 | history.clear() 96 | controller?.cancel() 97 | controller = null 98 | } 99 | subscribe { event -> 100 | val state = event.gameState 101 | history.add(state) 102 | logger.debug { "New state: $state" } 103 | gameModel.run { 104 | if(stepController || gameState.value == null || gameResult.value != null) { 105 | gameResult.set(null) 106 | gameState.set(state) 107 | } else { 108 | updateAvailableTurns(state.turn) 109 | } 110 | } 111 | } 112 | } 113 | 114 | @Throws(ReplayLoaderException::class) 115 | fun loadReplay(loader: ReplayLoader) { 116 | if(history.isNotEmpty()) 117 | throw ReplayLoaderException("Trying to load replay into a running game") 118 | val result = loader.loadHistory() 119 | history.addAll(result.first) 120 | logger.debug { "Loaded ${history.size} states from $loader" } 121 | if(history.isEmpty()) 122 | throw ReplayLoaderException("Replay history from $loader is empty") 123 | 124 | gameModel.availableTurns.set(history.last().turn) 125 | gameModel.gameResult.set(result.second) 126 | gameModel.playerNames.setAll( 127 | result.second?.scores?.keys 128 | ?.sortedBy { it.team.index } 129 | ?.map { it.displayName }.orEmpty() 130 | ) 131 | gameModel.gameState.set(history.first()) 132 | 133 | fire(GameReadyEvent()) 134 | } 135 | } 136 | 137 | class ReplayLoaderException(message: String): IOException(message) -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/game/PiranhasBoard.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view.game 2 | 3 | import javafx.application.Platform 4 | import javafx.geometry.Insets 5 | import javafx.geometry.Point2D 6 | import javafx.geometry.Pos 7 | import javafx.scene.Node 8 | import javafx.scene.effect.ColorAdjust 9 | import javafx.scene.effect.Glow 10 | import javafx.scene.input.KeyEvent 11 | import javafx.scene.layout.GridPane 12 | import sc.api.plugins.Coordinates 13 | import sc.gui.util.listenImmediately 14 | import sc.gui.view.GameBoard 15 | import sc.gui.view.PieceImage 16 | import sc.gui.view.transitionDuration 17 | import sc.plugin2026.FieldState 18 | import sc.plugin2026.GameState 19 | import sc.plugin2026.util.GameRuleLogic 20 | import sc.plugin2026.util.PiranhaConstants 21 | import tornadofx.* 22 | 23 | class PiranhasBoard: GameBoard() { 24 | 25 | private val gridSize 26 | get() = squareSize.div(PiranhaConstants.BOARD_LENGTH) 27 | 28 | val grid: GridPane = GridPane().addClass("grid").apply { 29 | squareSize.listenImmediately { size -> 30 | padding = Insets( 31 | size.toDouble() / 80, 32 | size.toDouble() / 80, 33 | size.toDouble() / 300, 34 | size.toDouble() / 200, 35 | ) 36 | } 37 | } 38 | 39 | override val root = hbox { 40 | this.alignment = Pos.CENTER 41 | vbox { 42 | this.alignment = Pos.CENTER 43 | add(grid) 44 | } 45 | } 46 | 47 | var selected: Node? = null 48 | val hovers = ArrayList() 49 | 50 | fun clearHovers() { 51 | logger.trace { "Clearing hovers: $hovers" } 52 | grid.children.removeAll(hovers) 53 | hovers.clear() 54 | } 55 | 56 | fun addToGrid(child: Node, coordinates: Coordinates) { 57 | grid.add(child, coordinates.x, PiranhaConstants.BOARD_LENGTH - 1 - coordinates.y) 58 | } 59 | 60 | override fun onNewState(oldState: GameState?, state: GameState?) { 61 | selected = grid 62 | logger.debug { "New State: $state" } 63 | grid.children.clear() 64 | hovers.clear() 65 | selected = null 66 | 67 | // this ensures proper sizing of the board 68 | (0 until PiranhaConstants.BOARD_LENGTH).forEach { y -> 69 | grid.add(PieceImage(gridSize, "squid").apply { opacity = 0.0 }, 0, y) 70 | grid.add(PieceImage(gridSize, "squid").apply { opacity = 0.0 }, y, 0) 71 | } 72 | 73 | state?.let { state -> 74 | val move = state.lastMove?.let { move -> 75 | if(oldState?.turn?.minus(state.turn) == -1) { 76 | move.from to GameRuleLogic.targetCoordinates(oldState.board, move) 77 | } else { 78 | null 79 | } 80 | } 81 | state.board.forEach { (pos: Coordinates, field: FieldState) -> 82 | val piece = PieceImage( 83 | gridSize, 84 | field.team?.let { team -> "${team}_${field.size}" } ?: field.name.lowercase()) 85 | 86 | addToGrid(piece, pos) 87 | if(pos == move?.second) { 88 | val offset = move.first - move.second 89 | logger.debug { "Animating piece $piece (${piece.translateX}|${piece.translateY}) along $move from $offset" } 90 | piece.effect = Glow(0.2) 91 | piece.translateX = offset.dx * gridSize.value 92 | piece.translateY = - offset.dy * gridSize.value 93 | piece.move(transitionDuration, Point2D.ZERO) 94 | } 95 | 96 | if(field.team == null) 97 | return@forEach 98 | piece.hoverProperty().addListener { _, _, hover -> 99 | if(selected == null) { 100 | if(hover) { 101 | Platform.runLater { 102 | addHovers(state, pos, field) 103 | } 104 | } else { 105 | if(field.team != state.currentTeam || !awaitingHumanMove.value) 106 | clearHovers() 107 | } 108 | } 109 | } 110 | piece.onLeftClick { 111 | if(field.team == state.currentTeam && awaitingHumanMove.value) { 112 | logger.debug { "Clicked own fish on $pos" } 113 | selected?.effect = null 114 | if(selected == piece) { 115 | clearHovers() 116 | selected = null 117 | return@onLeftClick 118 | } 119 | selected = piece 120 | piece.effect = Glow(0.6) 121 | addHovers(state, pos, field) 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | fun addHovers(state: GameState, pos: Coordinates, field: FieldState) { 129 | logger.trace { "Clearing hovers and adding for $pos in turn ${state.turn}" } 130 | clearHovers() 131 | 132 | val board = state.board 133 | GameRuleLogic.possibleMovesFor(board, pos).forEach { move -> 134 | val target = GameRuleLogic.targetCoordinates(board, move) 135 | val hover = PieceImage(gridSize, "${field.team}_${field.size}") 136 | 137 | val current = field.team == state.currentTeam 138 | hover.effect = ColorAdjust().apply { 139 | saturation = if(current && awaitingHumanMove.value) -0.4 else -0.9 140 | } 141 | if(current) 142 | hover.onLeftClick { sendHumanMove(move) } 143 | 144 | hovers.add(hover) 145 | addToGrid(hover, target) 146 | } 147 | } 148 | 149 | override fun handleKeyPress(state: GameState, keyEvent: KeyEvent): Boolean { 150 | return false 151 | } 152 | 153 | override fun renderHumanControls(state: GameState) { 154 | // not needed for piranhas, handled abovene 155 | } 156 | 157 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/GameCreationView.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import javafx.beans.binding.Bindings 4 | import javafx.beans.value.ObservableValue 5 | import javafx.geometry.Insets 6 | import javafx.geometry.Pos 7 | import javafx.scene.layout.Region 8 | import javafx.scene.text.TextAlignment 9 | import javafx.stage.FileChooser 10 | import sc.api.plugins.Team 11 | import sc.gui.AppStyle 12 | import sc.gui.controller.StartGame 13 | import sc.gui.controller.selectReplay 14 | import sc.gui.model.PlayerType 15 | import sc.gui.model.TeamSettings 16 | import sc.gui.model.TeamSettingsModel 17 | import sc.gui.replaysEnabled 18 | import tornadofx.* 19 | import java.io.File 20 | 21 | class GameCreationView: View() { 22 | private val playerSettingsModels = 23 | arrayOf(TeamSettings("Spieler 1", PlayerType.COMPUTER_SIMPLE), 24 | TeamSettings("Spieler 2", PlayerType.allowedValues().first())) 25 | .map { TeamSettingsModel(it) } 26 | 27 | override val root = borderpane { 28 | padding = Insets(AppStyle.spacing) 29 | if(replaysEnabled) 30 | top = hbox(AppStyle.spacing, Pos.CENTER_RIGHT) { 31 | button("Replay laden").action { selectReplay() } 32 | } 33 | center = form { 34 | alignment = Pos.CENTER 35 | label("Willkommen bei der Software-Challenge!") { 36 | addClass(AppStyle.heading) 37 | isWrapText = true 38 | textAlignment = TextAlignment.CENTER 39 | } 40 | // TODO label(guide) 41 | gridpane { 42 | hgap = AppStyle.spacing 43 | Team.values().forEach { team -> 44 | val settings = playerSettingsModels[team.index] 45 | fieldset(if(team == Team.ONE) "Erster Spieler" else "Zweiter Spieler") { 46 | alignment = Pos.TOP_CENTER 47 | paddingTop = AppStyle.spacing 48 | spacing = AppStyle.formSpacing 49 | textfield(settings.name) { 50 | promptText = "Name des Spielers ${team.index + 1}" 51 | required() 52 | } 53 | add(PlayerFileSelectFragment(team, settings)) 54 | gridpaneConstraints { 55 | rowIndex = 0 56 | columnIndex = team.index 57 | } 58 | } 59 | constraintsForColumn(team.index).percentWidth = 50.0 60 | } 61 | } 62 | } 63 | bottom = hbox(AppStyle.spacing, Pos.CENTER_RIGHT) { 64 | button("Erstellen") { 65 | action { 66 | playerSettingsModels.all { it.commit() } 67 | fire(StartGame(playerSettingsModels.map { it.item })) 68 | } 69 | enableWhen(Bindings.and(playerSettingsModels[0].valid, playerSettingsModels[1].valid)) 70 | whenDocked { 71 | runLater { 72 | requestFocus() 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | class PlayerFileSelectFragment(private val team: Team, private val settings: TeamSettingsModel): Fragment() { 81 | override val root = borderpane { 82 | prefHeight = AppStyle.fontSizeRegular.value * 9 83 | top = combobox(settings.type, PlayerType.allowedValues().toList()) { 84 | maxWidth = Double.MAX_VALUE 85 | } 86 | } 87 | 88 | private fun updatePlayerType() { 89 | // TODO: work with proper binding of property 90 | root.bottom = label("") 91 | when(settings.type.value as PlayerType) { 92 | PlayerType.COMPUTER -> { 93 | root.center = hbox(AppStyle.spacing) { 94 | button("Client wählen") { 95 | action { 96 | val selectedFile = chooseFile( 97 | "Client wählen", 98 | arrayOf( 99 | FileChooser.ExtensionFilter("Alle Dateien", "*.*"), 100 | FileChooser.ExtensionFilter("jar", "*.jar") 101 | ) 102 | ) 103 | if(selectedFile.isNotEmpty()) { 104 | settings.executable.value = selectedFile.first() 105 | } 106 | } 107 | } 108 | label("Wähle eine ausführbare Datei aus") 109 | } 110 | root.bottom = textflow { 111 | label("Ausgewählte Datei: ") 112 | label("") 113 | } 114 | } 115 | PlayerType.EXTERNAL -> { 116 | root.center = label("Spieler nach Erstellung des Spiels separat starten") 117 | } 118 | PlayerType.COMPUTER_SIMPLE -> { 119 | root.center = label("Ein einfacher, eingebauter Computerspieler") 120 | } 121 | PlayerType.COMPUTER_ADVANCED -> { 122 | root.center = label("Ein forgeschrittener, eingebauter Computerspieler") 123 | } 124 | PlayerType.HUMAN -> { 125 | root.center = label("Ein von Hand gesteuerter Spieler") 126 | } 127 | } 128 | (root.center as Region).paddingTop = AppStyle.formSpacing 129 | } 130 | 131 | init { 132 | settings.type.onChange { 133 | updatePlayerType() 134 | settings.validate() 135 | } 136 | settings.executable.onChange { 137 | root.bottom = textflow { 138 | val parentWidth = widthProperty() 139 | label("Ausgewählte Datei: ") 140 | label(settings.executable.value.absolutePath) { 141 | isWrapText = true 142 | prefWidthProperty().bind(parentWidth) 143 | } 144 | } 145 | } 146 | updatePlayerType() 147 | 148 | val obs: ObservableValue = settings.executable 149 | settings.validationContext.addValidator(root.bottom, obs, ValidationTrigger.OnChange()) { 150 | if(settings.type.value == PlayerType.COMPUTER && settings.executable.value == null) error("Bitte wähle eine ausführbare Datei aus") else null 151 | } 152 | settings.validate() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project are documented in this file. 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), 4 | 5 | See the [changelog of the backend](https://github.com/software-challenge/backend/blob/main/CHANGELOG.md) 6 | for details on our versioning scheme. 7 | 8 | ## 26.0.4 Piranhas Animated! - 2026-07-06 9 | 10 | ## 26.0.3 Piranhas Bugfixed - 2026-07-01 11 | Major bugs should be gone, 12 | only replays are untested 13 | 14 | ## 26.0 Basic Piranhas Game - 2026-06 15 | 16 | ## 2026 Game Piranhas 17 | 18 | ### 25.1.0 Add Advanced Player - 2025-02-13 19 | - add advanced player option 20 | - save replay on exit 21 | 22 | ### 25.0.6 Board Redesign - 2024-07-09 23 | - make some move interactions more intuitive 24 | - arrange the board as archimedean spiral 25 | - more animations 26 | 27 | ### 25.0.5 Tie Loose Ends - 2024-07-01 28 | - Fix small animation flash 29 | - End game with turn 60 30 | - Fix log opening on Linux 31 | - Update CI Pipeline 32 | 33 | ### 25.0.4 Smoothen Animations - 2024-06-26 34 | - Smoother animation of player figures 35 | - Fix erratic game start issues human vs human 36 | - Game Finish rule fixes in backend 37 | 38 | ### 25.0.3 UI Polish - 2024-06-25 39 | - label fields 40 | - show carrot costs and gains with icon 41 | - improve card move buttons 42 | - improve spacing 43 | - make example player a little smarter 44 | - animate moves 45 | 46 | ### 25.0.2 Adjustments - 2024-06-19 47 | - small UX improvements 48 | - display owned cards 49 | - update background and light/dark mode styling 50 | - update window title 51 | 52 | ### 25.0.1 Prototype - 2024-06-15 53 | 54 | ## 2025 Game Hase und Igel 55 | 56 | ### 24.2.4 Game generation and ending corrections - 2024-03-21 57 | 58 | ### 24.2.2 Detailed winner explanation and stuck ship highlighting - 2024-03-14 59 | 60 | ### 24.2.1 Backend: Fix incorrect winners - 2024-04-13 61 | 62 | ### 24.2.0 - 2024-03-12 63 | - Standardised game result messages from backend 64 | - Better gameplay button focus 65 | - Better handling of game speed 66 | 67 | ### 24.1.6 - 2024-02 68 | - Fix missing pixels between current 69 | - Expanded Interface Indicators 70 | 71 | ### 24.1.5 Proper Guidance - 2023-10-03 72 | - Add tooltips to all buttons 73 | - Fix player statistics (used to display current player in both) 74 | - Fix regression: Turn skip buttons advancing by 2 75 | 76 | ### 24.1.4 Guidance - 2023-10-02 77 | - Update instructions and merge into game creation screen 78 | 79 | ### 24.1.3 Edge Infos - 2023-09-21 80 | - Highlight available Push target fields 81 | - Add player stats to top screen corners 82 | - Bump JavaFX to 17.0.8 to fix crash on macOS 83 | 84 | ### 24.1.2 Graphics Fixes - 2023-09-20 85 | - Display goal flag also on field with current 86 | 87 | ### 24.1.1 Improve Board Layouting - 2023-09-15 88 | - Properly cut out current section 89 | 90 | ### 24.1.0 Interface Accessibility - 2023-09-11 91 | - Full Mouse Control 92 | - Modern Graphics and graphical indicators 93 | - Improve Keyboard Usage 94 | - Further improve Human Move validation 95 | - Reduce font size 96 | 97 | ### 24.0.7 Enhanced Visual Feedback - 2023-08-29 98 | - Eliminate lots of edge cases with human moves 99 | - Show current ship attributes 100 | - Show more details on game over 101 | - More Sizing Fixes 102 | 103 | ### 24.0.6 Human Keyboard Moves - 2023-08-24 104 | - Fix Board width issue on long straight as well as heavily bent boards 105 | - Enable Human Moves via Keyboard: 106 | + W: Advance 107 | + A/D: Turn 108 | + 0-5: Push in Direction (0 is RIGHT, then clockwise) 109 | + Acceleration is handled automatically 110 | + Confirm Move with S, Cancel with C 111 | 112 | ### 24.0.5 Simple Viewer - 2023-08-23 113 | - Can view computer players playing (no human moves yet) 114 | - Preliminary Graphics 115 | 116 | ## 2024 Game Mississippi Queen 117 | 118 | ### [23.0.2](https://github.com/software-challenge/backend/commits/23.0.2) Pretty Penguins - 2022-08-21 119 | - Overhaul of the game display 120 | 121 | ### [23.0.1](https://github.com/software-challenge/backend/commits/23.0.1) Rough Penguins - 2022-08-06 122 | - add new graphics and animations for Penguins 123 | - handle modifier keys (SHIFT/CTRL) when jumping turns (ebf5436) 124 | #### Minor Improvements 125 | - use Raleway Font (#83) 126 | - don't crash when loading an erroneous replay (13e9a28) 127 | - rotate board depending on startTeam parameter 128 | #### Under the hood 129 | - use OpenJFX 17 (fafae89) 130 | - remove kiosk mode stub 131 | - add debug startup option for use with ScenicView 132 | 133 | ## 2023 Game Hey, Danke für den Fisch (Penguins) - 2022-08 134 | 135 | ### [22.1.0](https://github.com/software-challenge/gui/commits/22.1.0) Fancying up - 2021-11 136 | - Ensure compatibility beyond Java 16 137 | - Animate figures 138 | - Persist preferences 139 | 140 | ### [22.0.3](https://github.com/software-challenge/gui/commits/22.0.3) - 2021-07-26 141 | - Fix annoying error when striking a figure in a human vs human match 142 | - Improve navigation & status display 143 | - Smoothen board interaction 144 | - Expand logging for beta version 145 | 146 | ### [22.0.2](https://github.com/software-challenge/gui/commits/22.0.2) Interface Polishing - 2021-07-16 147 | - Fix help links & add little usage guide 148 | - Polish game interface 149 | - Display amber count visually 150 | - Make example client a little smarter 151 | 152 | ### [22.0.1](https://github.com/software-challenge/gui/commits/22.0.1) - 2021-06-25 153 | - Proper Ostseeschach figures 154 | - Highlight possible moves on hover 155 | - Allow human players 156 | - Fix issues with game controls 157 | 158 | ### [22.0.0](https://github.com/software-challenge/gui/commits/22.0.0) - 2021-06-11 159 | - Major redesign of the layout 160 | - New Game Ostseeschach with basic animations and placeholder graphics 161 | 162 | ## 2022 Game Ostseeschach - 2021-06-11 163 | 164 | ### [21.4.0](https://github.com/software-challenge/gui/commits/21.4.0) - 2021-01-29 165 | - Automatically save replays & implement loading replays 166 | - Utilise TornadoFX ResourceLookup & EventStreams more extensively 167 | - Open help links properly on Linux-based systems 168 | 169 | ### [21.3.3](https://github.com/software-challenge/gui/commits/21.3.3) - 2021-02-26 170 | - Fix human getting wrongly colored piece ([#64](https://github.com/software-challenge/gui/pull/64)) 171 | - Stability improvements in backend 172 | 173 | ### [21.3.0](https://github.com/software-challenge/gui/commits/21.3.0) - 2021-01-29 174 | - Unify skip and pass button ([#63](https://github.com/software-challenge/gui/pull/63)) 175 | - Improve in-game status display ([#61](https://github.com/software-challenge/gui/pull/61)) 176 | - Remove accidental disabling of an entire menubar item before a game is started ([610d722e1f5a6056ebfcdf2ec4a56ed349fd5ba0](https://github.com/software-challenge/gui/commit/610d722e1f5a6056ebfcdf2ec4a56ed349fd5ba0)) 177 | - Improve some internal algorithms 178 | 179 | ### [21.2.1](https://github.com/software-challenge/gui/commits/21.2.1) - 2020-12-18 180 | - Cancel an existing game when starting a new one 181 | - Make in-game status display a little more concise 182 | 183 | ### [21.2.0](https://github.com/software-challenge/gui/commits/21.2.0) - 2020-12-14 184 | - Implement support for manually started clients 185 | - Add loading view when game is starting 186 | - Show winner info on game end (#58) 187 | - Highlight shapes that can currently be placed (#55) 188 | - Improve internal verification & publishing mechanisms 189 | 190 | ## 2021 - Game Blokus 191 | Replaced [Electron GUI](https://github.com/software-challenge/gui-electron) 192 | with new GUI based on [TornadoFX](https://github.com/edvin/tornadofx2). 193 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/ControlView.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view 2 | 3 | import javafx.application.Platform 4 | import javafx.beans.property.Property 5 | import javafx.beans.value.ObservableValue 6 | import javafx.geometry.Insets 7 | import javafx.geometry.Pos 8 | import javafx.scene.control.Button 9 | import javafx.scene.input.MouseEvent 10 | import javafx.scene.layout.HBox 11 | import javafx.scene.layout.Priority 12 | import io.github.oshai.kotlinlogging.KotlinLogging 13 | import sc.gui.AppStyle 14 | import sc.gui.GamePausedEvent 15 | import sc.gui.GameReadyEvent 16 | import sc.gui.events.* 17 | import sc.gui.model.GameModel 18 | import sc.gui.util.binding 19 | import sc.gui.util.booleanBinding 20 | import sc.gui.util.listen 21 | import sc.gui.util.listenImmediately 22 | import sc.gui.view.ControlView.GameControlState.* 23 | import tornadofx.* 24 | 25 | class ControlView: View() { 26 | private val logger = KotlinLogging.logger {} 27 | 28 | private val gameModel: GameModel by inject() 29 | private val gameControlState: Property = objectProperty(START) 30 | 31 | private val MouseEvent.modifierMultiplicator 32 | get() = when { 33 | isControlDown -> 100 34 | isShiftDown -> 10 35 | else -> 1 36 | } 37 | 38 | override val root = 39 | hbox { 40 | alignment = Pos.CENTER 41 | spacing = AppStyle.formSpacing 42 | val fontSize = parentProperty().doubleBinding { 43 | AppStyle.fontSizeRegular.value / (if(this.parent is HBox) 2 else 1) 44 | } 45 | button { 46 | prefWidthProperty().bind(fontSize.multiply(13)) 47 | gameControlState.listenImmediately { controlState -> 48 | logger.debug { "GameControlState $controlState" } 49 | isDisable = controlState == null 50 | controlState?.let { this.text = it.text } 51 | } 52 | action { 53 | isDisable = true 54 | fire(gameControlState.value.action) 55 | } 56 | } 57 | val prev = button { 58 | if(logger.isTraceEnabled) 59 | hoverProperty().listenImmediately { 60 | logger.trace { "$this: $padding on hover $it" } 61 | } 62 | disableWhen(gameModel.currentTurn.isEqualTo(0)) 63 | text = "◀" //"⏮" 64 | tooltip("Vorheriger Zug (Shift -10, Strg zum Anfang)") 65 | setOnMouseClicked { 66 | if(it.modifierMultiplicator == 1) 67 | return@setOnMouseClicked 68 | if(gameModel.atLatestTurn.value) 69 | fire(PauseGame(true)) 70 | fire(StepGame(-1 * it.modifierMultiplicator)) 71 | it.consume() 72 | } 73 | setOnAction { 74 | if(gameModel.atLatestTurn.value) 75 | fire(PauseGame(true)) 76 | fire(StepGame(-1)) 77 | it.consume() 78 | } 79 | } 80 | label { 81 | alignment = Pos.CENTER 82 | prefWidthProperty().bind(fontSize.multiply(7)) 83 | textProperty().bind( 84 | arrayOf>(gameModel.currentTurn, gameModel.availableTurns) 85 | .binding { (cur, all) -> "Zug " + if(cur != all || gameModel.gameOver.value) "$cur/$all" else cur } 86 | ) 87 | } 88 | button { 89 | if(logger.isTraceEnabled) 90 | hoverProperty().listenImmediately { 91 | logger.trace { "$this: $padding on hover $it" } 92 | } 93 | fixHoverInsets() 94 | disableProperty().bind( 95 | arrayOf>( 96 | gameModel.atLatestTurn, 97 | gameModel.isHumanTurn, 98 | gameModel.gameOver 99 | ).booleanBinding 100 | { (latest, human, end) -> 101 | logger.trace { "latest: $latest, human: $human, end: $end" } 102 | (latest && (human || end)).also { 103 | if(it && isFocused) 104 | Platform.runLater { prev.requestFocus() } 105 | } 106 | } 107 | ) 108 | text = "▶" //"⏭" 109 | tooltip("Nächster Zug (Shift +10, Strg zum Ende)") 110 | setOnMouseClicked { 111 | if(it.modifierMultiplicator == 1) 112 | return@setOnMouseClicked 113 | fire(StepGame(it.modifierMultiplicator)) 114 | if(gameControlState.value == START) gameControlState.value = PAUSED 115 | it.consume() 116 | } 117 | setOnAction { 118 | fire(StepGame(1)) 119 | if(gameControlState.value == START) gameControlState.value = PAUSED 120 | it.consume() 121 | } 122 | } 123 | group { 124 | svgpath("M75.694 480a48.02 48.02 0 0 1-42.448-25.571C12.023 414.3 0 368.556 0 320 0 160.942 128.942 32 288 32s288 128.942 288 288c0 48.556-12.023 94.3-33.246 134.429A48.018 48.018 0 0 1 500.306 480H75.694zM512 288c-17.673 0-32 14.327-32 32 0 17.673 14.327 32 32 32s32-14.327 32-32c0-17.673-14.327-32-32-32zM288 128c17.673 0 32-14.327 32-32 0-17.673-14.327-32-32-32s-32 14.327-32 32c0 17.673 14.327 32 32 32zM64 288c-17.673 0-32 14.327-32 32 0 17.673 14.327 32 32 32s32-14.327 32-32c0-17.673-14.327-32-32-32zm65.608-158.392c-17.673 0-32 14.327-32 32 0 17.673 14.327 32 32 32s32-14.327 32-32c0-17.673-14.327-32-32-32zm316.784 0c-17.673 0-32 14.327-32 32 0 17.673 14.327 32 32 32s32-14.327 32-32c0-17.673-14.327-32-32-32zm-87.078 31.534c-12.627-4.04-26.133 2.92-30.173 15.544l-45.923 143.511C250.108 322.645 224 350.264 224 384c0 35.346 28.654 64 64 64 35.346 0 64-28.654 64-64 0-19.773-8.971-37.447-23.061-49.187l45.919-143.498c4.039-12.625-2.92-26.133-15.544-30.173z") { 125 | // from tachometer.svg, see https://edencoding.com/svg-javafx/#svg-as-image and https://www.tutorialspoint.com/javafx/2dshapes_svgpath.htm 126 | this.scaleY = 0.1 127 | this.scaleX = 0.1 128 | } 129 | this.hboxConstraints { 130 | this.margin = Insets(AppStyle.spacing, 0.0, AppStyle.spacing, AppStyle.spacing) 131 | this.hGrow = Priority.NEVER 132 | } 133 | tooltip("Abspielgeschwindigkeit") 134 | } 135 | spinner( 136 | min = 0.0, 137 | max = 99.0, 138 | amountToStepBy = 2.0, 139 | editable = true, 140 | property = gameModel.stepSpeed, 141 | enableScroll = true, 142 | ) { 143 | tooltip("Abspielgeschwindigkeit") 144 | // TODO unfocus on normal character typed 145 | prefWidthProperty().bind(fontSize.multiply(6)) 146 | } 147 | // TODO depend on game: checkbox("Animationen", AppModel.animate) 148 | } 149 | 150 | init { 151 | subscribe { 152 | gameControlState.value = START 153 | } 154 | subscribe { event -> 155 | gameControlState.value = when { 156 | event.paused -> PAUSED 157 | else -> PLAYING 158 | } 159 | } 160 | arrayOf>( 161 | gameModel.atLatestTurn, 162 | gameModel.gameOver, 163 | gameModel.isHumanTurn 164 | ).listen { (latestTurn, end, human) -> 165 | when { 166 | latestTurn && end -> gameControlState.value = FINISHED 167 | latestTurn && human -> { 168 | gameControlState.value = PLAYING // To move on from "Start" text 169 | gameControlState.value = null // No pausing when human move is imminent 170 | } 171 | } 172 | } 173 | } 174 | 175 | /** Encapsulates the different actions of the GameControlButton. 176 | * @param action the event to fire when this state is invoked */ 177 | enum class GameControlState(val text: String, val action: FXEvent) { 178 | START("Start", PauseGame(false)), 179 | PLAYING("Anhalten", PauseGame(true)), 180 | PAUSED("Weiter", PauseGame(false)), 181 | FINISHED("Spiel beenden", TerminateGame(true)); 182 | } 183 | } 184 | 185 | // FIXME not properly debugged until today 186 | // why sometimes buttons start shrinking on hover 187 | fun Button.fixHoverInsets() = apply { 188 | //paddingProperty().bind(SimpleObjectProperty(Insets(AppStyle.formSpacing))) 189 | } 190 | -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/AppStyle.kt: -------------------------------------------------------------------------------- 1 | package sc.gui 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.scene.effect.BlurType 5 | import javafx.scene.effect.DropShadow 6 | import javafx.scene.layout.* 7 | import javafx.scene.paint.Color 8 | import javafx.scene.text.Font 9 | import javafx.scene.text.FontPosture 10 | import javafx.scene.text.FontWeight 11 | import javafx.scene.text.TextAlignment 12 | import sc.api.plugins.Team 13 | import sc.gui.model.AppModel 14 | import tornadofx.* 15 | 16 | class AppStyle: Stylesheet() { 17 | 18 | companion object { 19 | private val logger = KotlinLogging.logger { } 20 | 21 | private val resources = ResourceLookup(this) 22 | 23 | const val pieceOpacity = 1.0 24 | 25 | val fontSizeUnscaled = Font.getDefault().also { logger.debug("System Font: $it") }.size.pt 26 | val fontSizeRegular = fontSizeUnscaled * AppModel.scaling.value 27 | val fontSizeSmall = fontSizeRegular * 0.6 28 | val fontSizeBig = fontSizeRegular * 1.5 29 | val fontSizeHeader = fontSizeBig * 2.0 30 | 31 | val spacing = fontSizeRegular.value 32 | val formSpacing = spacing / 2 33 | val miniSpacing = formSpacing / 4 34 | 35 | // CLASSES 36 | val background by cssclass() 37 | 38 | val fullWidth by cssclass() 39 | val lightColorSchema by cssclass() 40 | val darkColorSchema by cssclass() 41 | 42 | val big by cssclass() 43 | val heading by cssclass() 44 | val statusLabel by cssclass() 45 | val plainLabel by cssclass() 46 | 47 | fun background() = 48 | StackPane( 49 | Region().apply { 50 | hgrow = Priority.ALWAYS 51 | vgrow = Priority.ALWAYS 52 | addClass(AppStyle.background) 53 | } 54 | ) 55 | 56 | init { 57 | arrayOf("Regular", "Bold", "Italic", "BoldItalic").forEach { 58 | Font.loadFont(resources["/fonts/Raleway-$it.ttf"], 0.0) 59 | } 60 | } 61 | } 62 | 63 | private fun themed(block: CssSelectionBlock.(theme: Theme) -> Unit) { 64 | lightColorSchema { 65 | block(Theme.LIGHT) 66 | } 67 | darkColorSchema { 68 | block(Theme.DARK) 69 | } 70 | } 71 | 72 | data class Theme(val isDark: Boolean, val base: Color, val background: Color) { 73 | val textColor: Color 74 | get() = background.invert() 75 | companion object { 76 | val LIGHT = Theme(false, c("#CCC"), c("#FFF")) 77 | val DARK = Theme(true, c("#222"), c("#111")) 78 | } 79 | } 80 | 81 | private fun Selectable.theme(block: CssSelectionBlock.(theme: Theme) -> Unit) { 82 | val inner = this 83 | themed { theme -> 84 | inner { 85 | block(theme) 86 | } 87 | } 88 | } 89 | 90 | init { 91 | themed { 92 | baseColor = it.base 93 | backgroundColor += it.background 94 | faintFocusColor = it.base 95 | } 96 | menuBar.theme { 97 | backgroundColor += it.background 98 | } 99 | contextMenu.theme { 100 | backgroundColor += it.base 101 | } 102 | themed { 103 | textFill = it.textColor 104 | textField { 105 | textFill = it.textColor 106 | backgroundColor += it.background 107 | } 108 | } 109 | 110 | root { 111 | fontFamily = "Raleway" 112 | fontSize = fontSizeRegular 113 | accentColor = Color.MEDIUMPURPLE 114 | } 115 | 116 | // Generic Components 117 | button { 118 | backgroundRadius = multi((box(fontSizeRegular))) 119 | borderRadius = backgroundRadius 120 | } 121 | ".small" { 122 | fontSize = fontSizeSmall 123 | } 124 | label.theme { theme -> 125 | effect = DropShadow(formSpacing, theme.background).apply { 126 | spread = 0.9 127 | blurType = BlurType.TWO_PASS_BOX 128 | } 129 | } 130 | label { 131 | and(plainLabel) { 132 | "-fx-effect".force("null") 133 | } 134 | } 135 | big { 136 | fontSize = fontSizeBig 137 | textAlignment = TextAlignment.CENTER 138 | fontWeight = FontWeight.BOLD 139 | } 140 | heading { 141 | fontSize = fontSizeHeader 142 | wrapText = true 143 | textAlignment = TextAlignment.CENTER 144 | fontWeight = FontWeight.BOLD 145 | } 146 | 147 | // Special Components 148 | legend { 149 | // label of GameCreationForm 150 | fontSize = fontSizeBig 151 | fontStyle = FontPosture.ITALIC 152 | } 153 | statusLabel { 154 | fontSize = fontSizeBig 155 | } 156 | fullWidth { 157 | prefWidth = 100.percent 158 | } 159 | 160 | piranhasStyles() 161 | } 162 | 163 | fun piranhasStyles() { 164 | background { 165 | opacity = 0.7 166 | backgroundColor += c("#88DAF7") 167 | backgroundImage += resources.url("/piranhas/water_b.png").toURI() 168 | backgroundRepeat += BackgroundRepeat.REPEAT to BackgroundRepeat.REPEAT 169 | } 170 | 171 | (1..3).forEach { size -> 172 | Team.entries.forEach { team -> 173 | ".${team}_${size}" { 174 | image = resources.url("/piranhas/${team.color}_${(96 + size).toChar()}.png") 175 | .toURI() 176 | } 177 | } 178 | } 179 | 180 | ".squid" { image = resources.url("/piranhas/squid.png").toURI() } 181 | ".grid" { 182 | backgroundImage += resources.url("/piranhas/grid-crop.png").toURI() 183 | backgroundSize += BackgroundSize(1.0, 1.0, true, true, false, false) 184 | } 185 | } 186 | 187 | fun huiStyles() { 188 | background { 189 | opacity = 0.5 190 | backgroundImage += resources.url("/hui/background_very_simple.png").toURI() 191 | backgroundRepeat += BackgroundRepeat.REPEAT to BackgroundRepeat.REPEAT 192 | } 193 | } 194 | 195 | fun mqStyles() { 196 | background { 197 | opacity = 0.7 198 | backgroundColor += c("#2a9b46") 199 | backgroundImage += resources.url("/mq/fields/background/background.png").toURI() 200 | backgroundRepeat += BackgroundRepeat.REPEAT to BackgroundRepeat.REPEAT 201 | } 202 | 203 | (0 until 12).forEach { 204 | ".passenger${it % 6}${it / 6}" { 205 | image = resources.url("/mq/fields/islands/passenger_island_${(97 + (it % 6)).toChar()}_${it / 6}.png") 206 | .toURI() 207 | } 208 | } 209 | ".goal" { image = resources.url("/mq/fields/goal.png").toURI() } 210 | 211 | ".island1" { image = resources.url("/mq/fields/islands/empty_island_A.png").toURI() } 212 | ".island2" { image = resources.url("/mq/fields/islands/empty_island_B.png").toURI() } 213 | ".island3" { image = resources.url("/mq/fields/islands/empty_island_D.png").toURI() } 214 | 215 | select(CssRule.c("water")) { 216 | image = resources.url("/mq/fields/water_textures/water_A.png").toURI() 217 | (0..19).forEach { frame -> 218 | and(CssRule.pc("idle$frame")) { 219 | image = resources.url("/mq/fields/water_textures/water_${(frame.div(5) + 65).toChar()}.png").toURI() 220 | } 221 | } 222 | } 223 | select(CssRule.c("stream")) { 224 | (0..19).forEach { frame -> 225 | and(CssRule.pc("idle$frame")) { 226 | image = resources.url("/mq/fields/stream_${(frame.div(4).mod(2) + 65).toChar()}.png").toURI() 227 | } 228 | } 229 | } 230 | 231 | ".border" { image = resources.url("/mq/fields/background/border_vertical.png").toURI() } 232 | ".border_inner" { image = resources.url("/mq/fields/background/border_inner_corner.png").toURI() } 233 | ".border_outer" { image = resources.url("/mq/fields/background/border_outer_corner.png").toURI() } 234 | ".fog" { image = resources.url("/mq/fields/background/fog_tile.png").toURI() } 235 | ".fog_border" { image = resources.url("/mq/fields/background/fog_border_beach_vertical.png").toURI() } 236 | ".fog_border_inner" { image = resources.url("/mq/fields/background/fog_border_beach_inner_corner.png").toURI() } 237 | ".fog_border_outer" { image = resources.url("/mq/fields/background/fog_border_beach_outer_corner.png").toURI() } 238 | ".fog_water_border" { image = resources.url("/mq/fields/background/fog_border_vertical.png").toURI() } 239 | ".fog_water_border_inner" { image = resources.url("/mq/fields/background/fog_border_inner_corner.png").toURI() } 240 | ".fog_water_border_outer" { image = resources.url("/mq/fields/background/fog_border_outer_corner.png").toURI() } 241 | 242 | arrayOf("ship_one", "ship_two").forEach { 243 | select(CssRule.c(it)) { image = resources.url("/mq/boats/$it.png").toURI() } 244 | "ab".forEach { ch -> 245 | select(CssRule.c(it + "_passenger_$ch")) { 246 | image = resources.url("/mq/boats/passengers/passenger_${ch}_$it.png").toURI() 247 | } 248 | } 249 | } 250 | arrayOf("half_speed", "full_speed").forEach { speed -> 251 | arrayOf("smoke", "waves").forEach { asset -> 252 | val full = asset + "_" + speed 253 | select(CssRule.c(full)) { 254 | image = resources.url("/mq/boats/$full.png").toURI() 255 | } 256 | } 257 | } 258 | (1..6).forEach { 259 | select(CssRule.c("coal$it")) { image = resources.url("/mq/boats/coal/coal_$it.png").toURI() } 260 | } 261 | } 262 | 263 | val gridHover by csspseudoclass() 264 | val gridLock by csspseudoclass() 265 | 266 | fun ostseeschachStyles() { 267 | val colorBackground = c("#36d2ff") 268 | 269 | Team.values().forEach { team -> 270 | ".${team.color}" { 271 | val color = team.color 272 | backgroundColor += c(color, 0.6).desaturate() 273 | and(gridHover) { 274 | backgroundColor += c(color, 0.6) 275 | } 276 | and(gridLock) { 277 | backgroundColor += c(color, 0.8) 278 | } 279 | } 280 | } 281 | 282 | CssRule.c("grid").theme { 283 | borderStyle += BorderStrokeStyle.DOTTED 284 | borderColor += box(if(it.isDark) colorBackground.brighter() else colorBackground.darker()) 285 | } 286 | 287 | background { 288 | opacity = 0.7 289 | backgroundColor += colorBackground 290 | } 291 | } 292 | 293 | } -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/game/PenguinsBoard.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view.game 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import javafx.animation.KeyFrame 5 | import javafx.application.Platform 6 | import javafx.beans.binding.Bindings 7 | import javafx.beans.value.ChangeListener 8 | import javafx.geometry.Orientation 9 | import javafx.geometry.Point2D 10 | import javafx.geometry.Pos 11 | import javafx.scene.Node 12 | import javafx.scene.effect.ColorAdjust 13 | import javafx.scene.effect.Glow 14 | import javafx.scene.layout.FlowPane 15 | import javafx.scene.layout.Pane 16 | import javafx.scene.layout.Region 17 | import javafx.scene.transform.Rotate 18 | import sc.api.plugins.Coordinates 19 | import sc.api.plugins.IGameState 20 | import sc.api.plugins.Team 21 | import sc.gui.AppStyle 22 | import sc.gui.controller.HumanMoveAction 23 | import sc.gui.model.GameModel 24 | import sc.gui.util.listenImmediately 25 | import sc.gui.view.PieceImage 26 | import sc.gui.view.ResizableImageView 27 | import sc.gui.view.animationDuration 28 | import sc.gui.view.transitionDuration 29 | import sc.plugin2023.Field 30 | import sc.plugin2023.GameState 31 | import sc.plugin2023.Move 32 | import sc.plugin2023.util.PenguinConstants 33 | import tornadofx.* 34 | 35 | private val logger = KotlinLogging.logger { } 36 | 37 | class PenguinBoard: View() { 38 | private val gameModel: GameModel by inject() 39 | private val gameState: GameState? 40 | get() = gameModel.gameState.value as? GameState 41 | 42 | private val pieces = HashMap() 43 | private val ice = HashMap() 44 | 45 | private val size = doubleProperty(16.0) 46 | private val boardSize = PenguinConstants.BOARD_SIZE * 2 47 | private val gridSize 48 | get() = size.value / boardSize * 0.8 49 | private val calculatedBlockSize = size.doubleBinding { gridSize * 1.9 } 50 | 51 | override val root = hbox { 52 | this.alignment = Pos.CENTER 53 | size.bind(Bindings.min(widthProperty(), heightProperty().multiply(1.6))) 54 | anchorpane { 55 | this.paddingAll = AppStyle.spacing 56 | val stateListener = ChangeListener { _, oldState, state -> 57 | clearTargetHighlights() 58 | if(state == null) { 59 | //children.remove(BOARD_SIZE.toDouble().pow(2).toInt(), children.size) 60 | grid.children.clear() 61 | pieces.clear() 62 | ice.clear() 63 | return@ChangeListener 64 | } 65 | 66 | rotate = state.startTeam.index * 180.0 67 | rotationAxis = Rotate.Y_AXIS 68 | // TODO finish pending movements 69 | logger.trace { "New state for board: ${state.longString()}" } 70 | 71 | val lastMove = 72 | arrayOf(state to state.lastMove, oldState to oldState?.lastMove?.reversed()).maxByOrNull { 73 | it.first?.turn ?: -1 74 | }!!.second 75 | // TODO tornadofx: nested CSS, Color.derive with defaults, configProperty, CSS important, selectClass/Pseudo 76 | // TODO sounds for figure movements 77 | lastMove?.let { move -> 78 | pieces.remove(move.from)?.let { piece -> 79 | val coveredPiece = pieces.remove(move.to) 80 | pieces[move.to] = piece 81 | parallelTransition { 82 | var cur = -1 83 | val moveType = 84 | if((oldState?.board?.getOrEmpty(move.to)?.fish ?: 0) > 1) "consume" 85 | else "move" 86 | timeline { 87 | cycleCount = 8 88 | this += KeyFrame(animationDuration, { 89 | cur = piece.nextFrame(moveType, cur, randomize = false) 90 | }) 91 | }.apply { setOnFinished { piece.nextFrame(moveType, cur, remove = true) } } 92 | children += piece.move( 93 | transitionDuration - animationDuration.multiply(2.0), 94 | Point2D(move.delta!!.dx * gridSize, move.delta!!.dy * gridSize), 95 | play = false 96 | ) { 97 | delay = animationDuration.multiply(1.0) 98 | setOnFinished { 99 | piece.translateX = 0.0 100 | piece.translateY = 0.0 101 | // TODO hack to fix ordering 102 | // set viewOrder in transition 103 | grid.children.remove(piece) 104 | addPiece(piece, move.to) 105 | logger.trace { "Tile $piece finished transition to ${state.board[move.to]} covering $coveredPiece at ${move.to} (highlight: $currentHighlight)" } 106 | if(currentHighlight != null && currentHighlight in arrayOf(piece, coveredPiece)) { 107 | highlightTargets(move.to) 108 | lockedHighlight = move.to 109 | } 110 | grid.children.remove(coveredPiece) 111 | if(lockedHighlight == move.to) 112 | lockedHighlight = null 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | state.board.forEach { (coordinates, field) -> 120 | if(field.isEmpty) { 121 | removePiece(pieces.remove(coordinates)) 122 | removePiece(ice.remove(coordinates)) 123 | } else { 124 | val ice = ice.getOrPut(coordinates) { 125 | createPiece("ice").also { addPiece(it, coordinates) } 126 | } 127 | val piece = field.penguin 128 | if(field.fish > 0) { 129 | removePiece(pieces.remove(coordinates)) 130 | if(ice.children.size < 2) { 131 | ice.children.add(FlowPane(Orientation.HORIZONTAL, *(0 until field.fish).map { 132 | ResizableImageView(calculatedBlockSize.div(2)).addClass("fish") 133 | }.toTypedArray()).apply { 134 | this.alignment = Pos.CENTER 135 | this.maxWidthProperty().bind(calculatedBlockSize.div(1)) 136 | this.translateYProperty().bind(calculatedBlockSize.divide(-10)) 137 | /* TODO why do they turn into lines? 138 | findings: 139 | - has to do with increasing y position 140 | - reducing the image width makes the issue much worse 141 | - unrelated to minHeight/minWidth of ImageView as well as "managed" property 142 | */ 143 | }) 144 | } 145 | ice.onLeftClick { 146 | if(gameState?.canPlacePenguin(coordinates) == true) 147 | humanMove(Move.set(coordinates)) 148 | else currentHighlightCoords?.let { 149 | if(state.board[it].penguin == state.currentTeam && 150 | state.penguinsPlaced && targetHighlights.contains(ice)) 151 | humanMove(Move(it, coordinates)) 152 | } 153 | } 154 | ice.setOnMouseEntered { 155 | if(gameState?.canPlacePenguin(coordinates) == true) 156 | ice.addHover(team = gameState?.currentTeam) 157 | (ice.effect as? ColorAdjust ?: ColorAdjust()).run { 158 | ice.effect = this 159 | brightness = -0.2 160 | } 161 | } 162 | ice.setOnMouseExited { 163 | (ice.effect as? ColorAdjust)?.takeIf { ice in targetHighlights }?.run { 164 | brightness = 0.0 165 | } ?: ice.removeHover() 166 | } 167 | ice.children.first().effect = null 168 | } else if(piece != null) { 169 | if(ice.children.size > 1) 170 | removePiece(ice.children.last(), parent = ice) 171 | ice.children.first().effect = ColorAdjust(piece.colorAdjust, 0.0, 0.0, 0.0) 172 | val penguin = pieces.getOrPut(coordinates) { 173 | addPiece(createPiece("penguin"), coordinates) 174 | } 175 | penguin.scaleX = -(piece.index.xor(state.startTeam.index) * 2 - 1.0) 176 | penguin.fade( 177 | transitionDuration, AppStyle.pieceOpacity * when { 178 | piece != state.currentTeam -> 0.7 179 | gameModel.atLatestTurn.value && gameState?.isOver == false -> 1.0 180 | else -> 0.9 181 | } 182 | ) 183 | penguin.nextFrame() 184 | penguin.setClass("inactive", piece != state.currentTeam) 185 | 186 | penguin.setOnMouseEntered { event -> 187 | if(lockedHighlight == null) { 188 | highlight(penguin, updateTargetHighlights = coordinates) 189 | event.consume() 190 | } else if(lockedHighlight != coordinates) { 191 | penguin.addHover() 192 | } 193 | } 194 | penguin.onLeftClick { 195 | lockedHighlight = 196 | if(coordinates == lockedHighlight || !isSelectable(coordinates)) { 197 | pieces[coordinates]?.let { highlight(it, false, coordinates) } 198 | null 199 | } else { 200 | coordinates.also { 201 | pieces[coordinates]?.let { 202 | highlight( 203 | it, 204 | true, 205 | coordinates 206 | ) 207 | } 208 | } 209 | } 210 | logger.trace { "Clicked $coordinates (lock at $lockedHighlight, current $currentHighlight)" } 211 | } 212 | penguin.setOnMouseExited { event -> 213 | if(lockedHighlight == null) { 214 | if(!isSelectable(coordinates)) { 215 | clearTargetHighlights() 216 | penguin.removeHover() 217 | currentHighlight = null 218 | } 219 | } else if(lockedHighlight != coordinates) { 220 | penguin.removeHover() 221 | } 222 | event.consume() 223 | } 224 | } else { 225 | logger.error { "Invalid State!" } 226 | } 227 | } 228 | //image.viewOrder = BOARD_SIZE - coordinates.y.toDouble() 229 | } 230 | } 231 | Platform.runLater { 232 | @Suppress("UNCHECKED_CAST") 233 | gameModel.gameState.addListener(stateListener as ChangeListener) 234 | stateListener.changed(null, null, gameModel.gameState.value) 235 | } 236 | } 237 | } 238 | 239 | val grid: Pane = root.children.first() as Pane 240 | 241 | private fun removePiece(piece: Node?, durationMultiplier: Double = 1.0, parent: Pane = grid) = 242 | piece?.fade(transitionDuration.multiply(durationMultiplier), 0.0) { 243 | setOnFinished { 244 | parent.children.remove(piece) 245 | } 246 | } 247 | 248 | /** Whether the piece at [coords] could be selected for a human move.. */ 249 | private fun isSelectable(coords: Coordinates) = 250 | pieces[coords]?.opacity == AppStyle.pieceOpacity && gameModel.isHumanTurn.value && gameState?.penguinsPlaced == true 251 | 252 | private var lockedHighlight: Coordinates? = null 253 | private var currentHighlight: Node? = null 254 | private var currentHighlightCoords: Coordinates? = null 255 | private var targetHighlights = ArrayList() 256 | 257 | private fun createPiece(type: String): PieceImage = 258 | PieceImage(calculatedBlockSize, type) 259 | 260 | private fun highlight(node: PieceImage, lock: Boolean = false, updateTargetHighlights: Coordinates? = null) { 261 | currentHighlight?.removeHover() 262 | updateTargetHighlights?.takeIf { it != lockedHighlight }?.let { highlightTargets(it) } 263 | node.addHover(lock) 264 | currentHighlight = node 265 | } 266 | 267 | private fun Node.removeHover() { 268 | effect = null 269 | } 270 | 271 | private fun Node.addHover(lock: Boolean = false, team: Team? = null): Node { 272 | effect = 273 | if(team == null) Glow(if(lock) 0.7 else 0.4) 274 | else ColorAdjust(team.colorAdjust, -0.6, 0.0, 0.0) 275 | return this 276 | } 277 | 278 | private fun clearTargetHighlights() { 279 | currentHighlightCoords = null 280 | currentHighlight?.removeHover() 281 | lockedHighlight?.let { pieces[it] }?.removeHover() 282 | lockedHighlight = null 283 | targetHighlights.forEach { it.removeHover() } 284 | targetHighlights.clear() 285 | } 286 | 287 | private fun highlightTargets(position: Coordinates) { 288 | clearTargetHighlights() 289 | currentHighlightCoords = position 290 | 291 | gameState?.let { state -> 292 | targetHighlights.addAll( 293 | state.board.possibleMovesFrom(position) 294 | .also { logger.debug { "highlighting possible moves from $position: $it" } } 295 | .mapNotNull { move -> 296 | ice[move.to]?.addHover(team = state.board[position].penguin) 297 | }) 298 | } 299 | } 300 | 301 | private fun humanMove(move: Move) { 302 | if(gameModel.atLatestTurn.value && gameModel.isHumanTurn.value) { 303 | fire(HumanMoveAction(move.also { logger.debug { "Human Move: $it" } })) 304 | } 305 | } 306 | 307 | private fun addPiece(node: T, coordinates: Coordinates): T { 308 | if(grid.children.contains(node)) 309 | logger.warn { "Attempting to add duplicate grid child at $coordinates: $node" } 310 | else 311 | grid.add(node) 312 | size.listenImmediately { 313 | //logger.trace("$node at $coordinates block size: $it") 314 | node.anchorpaneConstraints { 315 | leftAnchor = coordinates.x * gridSize 316 | bottomAnchor = (PenguinConstants.BOARD_SIZE - coordinates.y) * gridSize 317 | } 318 | } 319 | return node 320 | } 321 | } 322 | 323 | private fun Node.setClass(className: String, add: Boolean = true) = 324 | if(add) addClass(className) else removeClass(className) 325 | 326 | val Team.colorAdjust 327 | get() = this.index - 0.4 -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/game/HuIBoard.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view.game 2 | 3 | import javafx.animation.KeyFrame 4 | import javafx.animation.Timeline 5 | import javafx.geometry.Orientation 6 | import javafx.geometry.Point2D 7 | import javafx.geometry.Pos 8 | import javafx.scene.Group 9 | import javafx.scene.Node 10 | import javafx.scene.control.Button 11 | import javafx.scene.control.Label 12 | import javafx.scene.control.Tooltip 13 | import javafx.scene.effect.ColorAdjust 14 | import javafx.scene.input.KeyEvent 15 | import javafx.scene.layout.* 16 | import javafx.util.Duration 17 | import sc.api.plugins.Team 18 | import sc.gui.AppStyle 19 | import sc.gui.util.listenImmediately 20 | import sc.gui.view.GameBoard 21 | import sc.gui.view.ResizableImageView 22 | import sc.gui.view.fixHoverInsets 23 | import sc.plugin2025.* 24 | import sc.plugin2025.Field 25 | import sc.plugin2025.util.HuIConstants 26 | import tornadofx.* 27 | import kotlin.math.* 28 | 29 | class HuIBoard: GameBoard() { 30 | val grid = AnchorPane() 31 | val cards = Array(2) { VBox() } 32 | 33 | private val graphicSize = squareSize.doubleBinding { 34 | minOf( 35 | root.width.div(12 + 4 /* cards on the sides */), 36 | viewHeight / 12 37 | ) 38 | } 39 | 40 | override val root: Region = HBox().apply { 41 | this.alignment = Pos.CENTER 42 | this.children.addAll(cards) 43 | cards.forEachIndexed { index, card -> 44 | card.alignment = if(index == 0) Pos.CENTER_LEFT else Pos.CENTER_RIGHT 45 | card.hgrow = Priority.SOMETIMES 46 | runLater { 47 | card.prefWidthProperty().bind(graphicSize.multiply(2)) 48 | } 49 | } 50 | this.children.add(1, grid) 51 | } 52 | 53 | private val emptyRegion = Region() 54 | private val fields: Array = Array(HuIConstants.NUM_FIELDS) { emptyRegion } 55 | private var timeline: Timeline? = null 56 | private val playerEffects = Team.values().map { ColorAdjust() } 57 | private val players = Team.values().map { 58 | createImage("player_" + it.color, 1.3).apply { 59 | effect = playerEffects[it.index] 60 | isMouseTransparent = true 61 | } 62 | } 63 | /** Nodes to clear between moves. */ 64 | private val toClear = ArrayList() 65 | 66 | override fun onNewState(oldState: GameState?, state: GameState?) { 67 | if(oldState?.board != state?.board) { 68 | // Clear grid on new game 69 | grid.clear() 70 | 71 | // These are spacers that prevent curious shifting of the gameboard 72 | // from player movement protruding view boundaries 73 | putOnPosition(createImage("player_red", 1.6).apply { 74 | opacity = 0.0; isMouseTransparent = true }, 20, false) 75 | putOnPosition(createImage("player_red", 1.6).apply { 76 | opacity = 0.0; isMouseTransparent = true }, 12, false) 77 | 78 | (state ?: return).board.fields.withIndex().forEach { (index, field) -> 79 | logger.trace { "Adding Field $field" } 80 | fields[index] = putOnPosition( 81 | createImage("field_" + field.name).apply { 82 | isPickOnBounds = true 83 | }, 84 | index, 85 | false 86 | ) 87 | if(index != 0) 88 | runLater { // So the labels are in front of the fields 89 | putOnPosition( 90 | Label(index.toString()).apply { 91 | addClass("small") 92 | isMouseTransparent = true 93 | }, 94 | index, 95 | false 96 | ) 97 | } 98 | } 99 | } else { 100 | toClear.forEach { 101 | grid.children.remove(it) 102 | } 103 | toClear.clear() 104 | fields.forEach { 105 | it.onMouseClicked = null 106 | it.effect = null 107 | } 108 | } 109 | 110 | if(state == null || oldState == state) 111 | return 112 | 113 | val animState = oldState?.takeIf { 114 | state.succeedsState(it) && state.lastMove is Advance 115 | } 116 | Team.values().forEach { team -> 117 | putOnPosition(players[team.index], (animState ?: state).getHare(team).position) 118 | } 119 | 120 | fun highlightPiece(team: Team) { 121 | timeline { 122 | keyframe(Duration.ZERO) { 123 | playerEffects.forEach { 124 | keyvalue(it.brightnessProperty(), it.brightness) 125 | } 126 | } 127 | keyframe(Duration.seconds(animFactor / 2)) { 128 | keyvalue( 129 | playerEffects[team.opponent().index].brightnessProperty(), 130 | 0.0 131 | ) 132 | keyvalue( 133 | playerEffects[team.index].brightnessProperty(), 134 | contrastFactor / 2 135 | ) 136 | } 137 | } 138 | } 139 | 140 | val activeTeam = (animState ?: state).currentTeam 141 | val piece = players[activeTeam.index] 142 | highlightPiece(activeTeam) 143 | fun movePiece() { 144 | val finalPos = state.getHare(activeTeam).position 145 | val coords = Point2D(piece.layoutX, piece.layoutY) 146 | piece.isVisible = false 147 | putOnPosition(piece, finalPos) 148 | root.layout() 149 | 150 | val offset = 151 | if(state.players.first().position == state.players.last().position) { 152 | Point2D(AppStyle.fontSizeSmall.value * (activeTeam.index - 1), AppStyle.fontSizeSmall.value * activeTeam.index) 153 | } else { 154 | Point2D.ZERO 155 | } 156 | piece.translateX = coords.x - piece.layoutX + piece.translateX 157 | piece.translateY = coords.y - piece.layoutY + piece.translateY 158 | piece.isVisible = true 159 | parallelTransition { 160 | this.children.add(piece.move( 161 | Duration.seconds(animFactor), 162 | destination = offset, 163 | play = false, 164 | ) { 165 | setOnFinished { 166 | if(state == gameState) 167 | highlightPiece(state.currentTeam) 168 | } 169 | }) 170 | oldState?.takeIf { state.succeedsState(it) }?.let { old -> 171 | state.players.forEach { player -> 172 | var carrotDiff = player.carrots - old.getHare(player.team).carrots 173 | val m = state.lastMove 174 | if(m is Advance && player.team == old.currentTeam) 175 | carrotDiff += m.cost 176 | if(carrotDiff != 0) { 177 | val carrotPopup = carrotCost(carrotDiff, player.position) 178 | this.children.addAll( 179 | carrotPopup.move( 180 | Duration.seconds(animFactor), 181 | Point2D(0.0, graphicSize.value * -1.3), 182 | play = false, 183 | ), 184 | carrotPopup.fade( 185 | Duration.seconds(animFactor * 4), 186 | 0.0, 187 | play = false, 188 | ) 189 | ) 190 | } 191 | } 192 | } 193 | } 194 | } 195 | animState?.let { st -> 196 | val pos = st.currentPlayer.position 197 | runLater { 198 | timeline?.stop() 199 | timeline = timeline(play = true) { 200 | val dist = (state.lastMove as Advance).distance 201 | var frame = 0 202 | keyFrames.add( 203 | KeyFrame(Duration.seconds(animFactor), 204 | { 205 | logger.trace { "Animating $piece to $frame" } 206 | if(frame < dist) 207 | putOnPosition(piece, pos + ++frame) 208 | }) 209 | ) 210 | rate = dist.toDouble() 211 | cycleCount = (dist * 1.3).toInt() + 1 212 | setOnFinished { movePiece() } 213 | } 214 | } 215 | } ?: movePiece() 216 | 217 | cards[activeTeam.index].apply { 218 | clear() 219 | children.addAll(state.getHare(activeTeam).getCards().map { createImage(it.graphicName(), 1.5) }) 220 | } 221 | } 222 | 223 | private var spiralFactor = 0.0 224 | /** Translated from https://stackoverflow.com/questions/78472829/equidistant-points-along-an-archimedean-spiral-with-fixed-gap-between-points-and*/ 225 | private fun spiral(radius: Double, numCycles: Double, nPoints: Int): DoubleArray { 226 | val dr = radius / numCycles 227 | val thetaMax = 2 * PI * numCycles 228 | val a = radius / thetaMax 229 | spiralFactor = radius / (2 * PI * numCycles.toInt()) 230 | val sMax = (a / 2) * (thetaMax * sqrt(1 + thetaMax.pow(2)) + ln(thetaMax + sqrt(1 + thetaMax.pow(2)))) 231 | val s = DoubleArray(nPoints) { dr / 2 + it * (sMax - dr / 2) / nPoints } 232 | 233 | val theta = DoubleArray(nPoints) 234 | for(i in s.indices) { 235 | var t = 0.0 236 | var told = t + 1 237 | while(abs(t - told) > 1.0e-10) { 238 | told = t 239 | t = sqrt((-1 + sqrt(1 + 4 * (2 * s[i] / a - ln(t + sqrt(1 + t.pow(2)))).pow(2))) / 2) 240 | } 241 | theta[i] = t 242 | } 243 | return theta 244 | } 245 | 246 | val spiralRadius = 5.0 247 | val archimedeanSpiral = spiral(spiralRadius, 3.65, HuIConstants.NUM_FIELDS).reversed() 248 | 249 | private fun putOnPosition(node: T, position: Int, clear: Boolean = true): T { 250 | if(clear) { 251 | grid.children.remove(node) 252 | toClear.add(node) 253 | } 254 | grid.add(node) 255 | val pos = archimedeanSpiral[position] 256 | graphicSize.listenImmediately { size -> 257 | val size = size.toDouble() 258 | node.anchorpaneConstraints { 259 | leftAnchor = size * (spiralFactor * pos * cos(pos) + spiralRadius) * 1.2 260 | bottomAnchor = size * (spiralFactor * pos * sin(pos) + spiralRadius) 261 | } 262 | } 263 | return node 264 | } 265 | 266 | private fun createImage(graphic: String, scale: Double? = null) = 267 | ResizableImageView(scale?.let { graphicSize.multiply(scale) } ?: graphicSize) 268 | .also { it.image = resources.image(huiGraphic(graphic)) } 269 | 270 | override fun renderHumanControls(state: GameState) { 271 | if(state.mustEatSalad()) { 272 | val pos = state.currentPlayer.position 273 | fields[pos].onClickMove(EatSalad) 274 | putOnPosition( 275 | Button("Salat fressen").apply { 276 | translateYProperty().bind(graphicSize.divide(-2)) 277 | addClass("small") 278 | onLeftClick { sendHumanMove(EatSalad) } 279 | }, 280 | pos 281 | ) 282 | return 283 | } 284 | 285 | state.possibleExchangeCarrotMoves().forEach { car -> 286 | putOnPosition( 287 | Button(carrotCostString(car.amount)).apply { 288 | fixHoverInsets() 289 | if(logger.isTraceEnabled) 290 | hoverProperty().listenImmediately { 291 | logger.trace { "$this: $padding on hover $it" } 292 | } 293 | translateX = AppStyle.spacing 294 | translateYProperty().bind(graphicSize.multiply(-car.amount / 20.0 - .2)) 295 | onLeftClick { sendHumanMove(car) } 296 | }, 297 | state.currentPlayer.position 298 | ) 299 | } 300 | 301 | val currentPos = state.currentPlayer.position 302 | val maxAdvance = GameRuleLogic.calculateMoveableFields(state.currentPlayer.carrots) 303 | val fallBack = state.nextFallBack() 304 | 305 | fields.forEachIndexed { targetPos, node -> 306 | val distance = targetPos - currentPos 307 | when { 308 | distance <= 0 -> { 309 | if(fallBack != targetPos) 310 | return@forEachIndexed 311 | node.onClickMove(FallBack) 312 | carrotCost(distance * -10, targetPos) 313 | } 314 | 315 | else -> { 316 | if(currentPos + maxAdvance < targetPos || state.checkAdvance(distance) != null) 317 | return@forEachIndexed 318 | val flow = FlowPane(Orientation.HORIZONTAL).apply { 319 | maxWidthProperty().bind(graphicSize) 320 | hgap = AppStyle.miniSpacing 321 | vgap = AppStyle.miniSpacing 322 | } 323 | var totalCards = 0 324 | state.possibleCardMoves(distance)?.also { 325 | node.darken() 326 | putOnPosition( 327 | Group(Group(flow).apply { isManaged = false }).apply { 328 | translateX = AppStyle.spacing 329 | translateY = -AppStyle.formSpacing 330 | }, 331 | targetPos 332 | ) 333 | totalCards = it.sumOf { it.getCards().size } 334 | }?.forEach { advance -> 335 | val cards = advance.getCards() 336 | var suffix = "" 337 | if(state.board.getField(targetPos) == Field.MARKET || 338 | cards.getOrNull(cards.lastIndex - 1)?.let { 339 | val clone = state.clonePlayer() 340 | it.play(clone) 341 | clone.currentField == Field.MARKET 342 | } == true 343 | ) { 344 | suffix = " kaufen" 345 | } 346 | flow.add(Button().apply { 347 | paddingAll = AppStyle.miniSpacing 348 | hbox { 349 | cards.map { 350 | add(ResizableImageView(graphicSize.multiply(.6 / totalCards + if(suffix.isEmpty()) .15 else .1)).apply { 351 | image = resources.image(huiGraphic(it.graphicName())) 352 | }) 353 | } 354 | if(suffix.isNotEmpty()) 355 | children.add(children.size - 1, Label("+").hboxConstraints { 356 | alignment = Pos.CENTER 357 | }) 358 | } 359 | tooltip = Tooltip(cards.joinToString(" dann ") { it.label } + suffix) 360 | onLeftClick { sendHumanMove(advance) } 361 | }) 362 | } ?: node.onClickMove(Advance(distance)) 363 | carrotCost(-GameRuleLogic.calculateCarrots(distance), targetPos) 364 | } 365 | } 366 | } 367 | } 368 | 369 | private fun Node.onClickMove(move: Move) { 370 | darken() 371 | onLeftClick { sendHumanMove(move) } 372 | } 373 | 374 | private fun Node.darken() { 375 | effect = ColorAdjust().apply { brightness = contrastFactor * -0.6 } 376 | } 377 | 378 | private fun carrotCost(value: Int, position: Int) = 379 | putOnPosition(Label(carrotCostString(value)).apply { 380 | translateY = -.8 * graphicSize.value 381 | isMouseTransparent = true 382 | }, position) 383 | 384 | override fun handleKeyPress(state: GameState, keyEvent: KeyEvent): Boolean { 385 | return false 386 | } 387 | 388 | } 389 | 390 | private fun huiGraphic(graphic: String) = 391 | "/hui/${graphic.lowercase()}.png" 392 | 393 | private fun carrotCostString(value: Int) = 394 | "▾ ${if(value > 0) "+" else ""}${value}" 395 | 396 | private fun Card.graphicName() = 397 | "card_" + this.name.replace("_", "") -------------------------------------------------------------------------------- /src/main/kotlin/sc/gui/view/game/MississippiBoard.kt: -------------------------------------------------------------------------------- 1 | package sc.gui.view.game 2 | 3 | import javafx.animation.Animation 4 | import javafx.animation.SequentialTransition 5 | import javafx.animation.Transition 6 | import javafx.application.Platform 7 | import javafx.beans.binding.Bindings 8 | import javafx.beans.property.SimpleDoubleProperty 9 | import javafx.geometry.Point2D 10 | import javafx.geometry.Pos 11 | import javafx.scene.Node 12 | import javafx.scene.control.Alert 13 | import javafx.scene.control.Label 14 | import javafx.scene.effect.ColorAdjust 15 | import javafx.scene.effect.DropShadow 16 | import javafx.scene.input.KeyCode 17 | import javafx.scene.input.KeyEvent 18 | import javafx.scene.layout.AnchorPane 19 | import javafx.scene.layout.Pane 20 | import javafx.scene.layout.VBox 21 | import javafx.scene.paint.Color 22 | import javafx.scene.shape.Rectangle 23 | import javafx.util.Duration 24 | import sc.api.plugins.CubeCoordinates 25 | import sc.api.plugins.CubeDirection 26 | import sc.api.plugins.Team 27 | import sc.gui.AppStyle 28 | import sc.gui.util.listenImmediately 29 | import sc.gui.view.GameBoard 30 | import sc.gui.view.PieceImage 31 | import sc.plugin2024.* 32 | import sc.plugin2024.Field 33 | import sc.plugin2024.actions.Accelerate 34 | import sc.plugin2024.actions.Advance 35 | import sc.plugin2024.actions.Push 36 | import sc.plugin2024.actions.Turn 37 | import sc.plugin2024.util.MQConstants 38 | import tornadofx.* 39 | import kotlin.math.absoluteValue 40 | import kotlin.math.pow 41 | 42 | class MississippiBoard: GameBoard() { 43 | 44 | private fun GameState.visibleBoard(): List = 45 | let { state -> 46 | state.board.segments.slice( 47 | state.ships.map { state.board.segmentIndex(it.position) }.sorted() 48 | .let { IntRange((it.first() - 1).coerceAtLeast(0), state.board.segments.lastIndex) } 49 | ) 50 | } 51 | 52 | private val gridSize: Double 53 | get() = gameState?.visibleBoard()?.rectangleSize?.let { 54 | minOf( 55 | root.scene?.width?.div(it.x + 2) ?: 64.0, 56 | viewHeight / (it.y + 2) * 1.1 57 | ) * 1.3 58 | } ?: 100.0 59 | 60 | private val grid: Pane = AnchorPane().apply { 61 | paddingAll = 0.0 62 | } 63 | 64 | override val root = hbox { 65 | viewOrder = 3.0 66 | alignment = Pos.CENTER 67 | vbox { 68 | alignment = Pos.CENTER 69 | paddingAll = 0.0 70 | group(listOf(grid)) { 71 | paddingAll = 0.0 72 | } 73 | } 74 | } 75 | 76 | private val calculatedBlockSize = SimpleDoubleProperty(10.0) 77 | private fun fontSizeFromBlockSize(factor: Double = .3) = 78 | calculatedBlockSize.stringBinding { "-fx-font-size: ${it?.toDouble()?.times(factor)}" } 79 | 80 | private var originalState: GameState? = null 81 | private val humanMove = ArrayList() 82 | 83 | private var transition: Transition? = null 84 | 85 | private fun Ship.canAdvance() = 86 | coal + movement + freeAcc > 0 && 87 | // negative movement points are turned into acceleration 88 | speed - movement < MQConstants.MAX_SPEED 89 | 90 | private fun nameSpeed(speed: Int): String? = 91 | if(speed > 3) { 92 | "full" 93 | } else if(speed > 1) { 94 | "half" 95 | } else { 96 | null 97 | } 98 | 99 | init { 100 | Platform.runLater { 101 | calculatedBlockSize.bind( 102 | gameModel.gameState.doubleBinding( 103 | gameModel.gameResult, 104 | gameModel.atLatestTurn, 105 | grid.parentProperty(), 106 | root.widthProperty(), 107 | root.heightProperty(), 108 | grid.widthProperty(), 109 | grid.heightProperty() 110 | ) { gridSize }) 111 | grid.apply { 112 | clipProperty().bind( 113 | Bindings.createObjectBinding( 114 | { Rectangle(0.0, -gridSize, root.width, viewHeight) }, 115 | gameModel.gameState, 116 | widthProperty(), 117 | parentProperty(), 118 | root.widthProperty(), 119 | root.heightProperty() 120 | ) 121 | ) 122 | } 123 | } 124 | } 125 | 126 | override fun onNewState(oldState: GameState?, state: GameState?) { 127 | if(state == null) { 128 | return 129 | } 130 | grid.children.clear() 131 | var wasHumanMove = false 132 | if(state.turn != oldState?.turn) { 133 | humanMove.clear() 134 | if(originalState != oldState) { 135 | wasHumanMove = true 136 | } 137 | originalState = state 138 | } 139 | logger.trace { "New state for board: ${state.longString()}" } 140 | 141 | state.currentShip.movement += state.currentShip.maxAcc 142 | val pushes = state.getPossiblePushs() 143 | state.currentShip.movement -= state.currentShip.maxAcc 144 | logger.trace { "Available Pushes: $pushes"} 145 | 146 | val neighbors = hashMapOf>() 147 | state.board.forEachField { cubeCoordinates, field -> 148 | CubeDirection.values().forEach { dir -> 149 | val coord = cubeCoordinates + dir.vector 150 | if(state.board[coord] == null) 151 | neighbors.getOrPut(coord) { ArrayList() }.add(dir) 152 | } 153 | createPiece( 154 | (if(field == Field.GOAL) Field.WATER else field).let { 155 | it.toString().lowercase() + when(it) { 156 | Field.ISLAND -> cubeCoordinates.hashCode().mod(3) + 1 157 | else -> "" 158 | } 159 | } 160 | ).also { piece -> 161 | piece.nextFrame() 162 | if(state.board.segmentIndex(cubeCoordinates).mod(2) == 1) 163 | piece.effect = ColorAdjust(0.0, 0.0, -contrastFactor / 3, 0.0) 164 | if(field.isEmpty) { 165 | piece.viewOrder++ 166 | val push = pushes.firstOrNull { 167 | state.currentShip.position + it.direction.vector == cubeCoordinates 168 | } 169 | if(push != null) { 170 | logger.debug { "Registering Push '$push' for $piece" } 171 | piece.glow(.4) 172 | piece.onHover { hover -> 173 | // TODO hover not recognized when stream is on top 174 | piece.glow(if(hover) 1 else .4) 175 | } 176 | piece.tooltip("Gegenspieler in Richtung ${push.direction} abdrängen (Taste ${push.direction.ordinal})") 177 | piece.onLeftClick { addHumanAction(push) } 178 | } 179 | } 180 | state.board.getFieldCurrentDirection(cubeCoordinates)?.let { dir -> 181 | addPiece( 182 | createPiece("stream").also { piece -> 183 | piece.rotate = dir.angle.toDouble() 184 | piece.nextFrame() 185 | }, 186 | cubeCoordinates 187 | ) 188 | } 189 | addPiece(piece, cubeCoordinates) 190 | } 191 | if(field == Field.GOAL) { 192 | addPiece(createPiece(field.toString().lowercase()), cubeCoordinates) 193 | } 194 | } 195 | 196 | val tip = state.board.segments.last().center + state.board.nextDirection.vector * 2 197 | val fog = ArrayList() 198 | if(state.board[tip] != Field.GOAL) { 199 | Segment.empty( 200 | state.board.segments.last().center + state.board.nextDirection.vector * 4, 201 | state.board.nextDirection 202 | ).forEachField { cubeCoordinates, _ -> fog.add(cubeCoordinates) } 203 | fog.forEach { cubeCoordinates -> 204 | CubeDirection.values().forEach { dir -> 205 | val coord = cubeCoordinates + dir.vector 206 | if(!fog.contains(coord) && state.board[coord] == null) 207 | neighbors.getOrPut(coord) { ArrayList() }.add(dir) 208 | } 209 | addPiece(createPiece("fog"), cubeCoordinates) 210 | } 211 | 212 | val excludeTip = state.board.segments.last().center + state.board.nextDirection.vector * 6 213 | val excluded = 214 | CubeDirection.values() 215 | .flatMap { listOf(excludeTip + it.vector, excludeTip + it.vector + (it + 1).vector) } 216 | excluded.forEach { exclude -> 217 | CubeDirection.values().forEach { dir -> 218 | val coord = exclude + dir.vector 219 | if(state.board[coord] == null) { 220 | neighbors[coord]?.add(dir) 221 | } 222 | } 223 | neighbors.remove(exclude) 224 | } 225 | } 226 | 227 | neighbors.forEach { (coords, dirs) -> 228 | addPiece( 229 | createPiece( 230 | when { 231 | state.board.neighboringFields(coords).all { it == null } -> "fog_" 232 | fog.contains(coords) -> "fog_water_" 233 | else -> "" 234 | } + 235 | when(dirs.size) { 236 | 1 -> "border_inner" 237 | 2 -> "border" 238 | 3 -> "border_outer" 239 | else -> { 240 | logger.warn { "Piece at $coords has wrong border directions: $dirs" } 241 | "" 242 | } 243 | } 244 | ).apply { 245 | if(fog.contains(coords)) 246 | addChild("water", 0) 247 | this.rotate = 248 | (dirs.single { dir -> dirs.all { dir.turnCountTo(it) >= 0 } } - (if(dirs.size == 1) 5 else 4)).angle.toDouble() 249 | }, 250 | coords 251 | ) 252 | } 253 | 254 | // TODO issues when double move through overtaking 255 | val animState = oldState?.clone()?.takeIf { 256 | !wasHumanMove && state.turn - 1 == it.turn && state.lastMove != null 257 | } 258 | 259 | fun PieceImage.shipBowWaveSpeed(speed: Int) { 260 | this.children.removeIf { it.styleClass.any { it.startsWith("waves") } } 261 | nameSpeed(speed)?.let { this.addChild("waves_${it}_speed", 0) } 262 | } 263 | 264 | fun PieceImage.addPassenger(shipName: String, passenger: Int) = 265 | addChild("${shipName}_passenger_${(96 + passenger).toChar()}") 266 | 267 | val pieces = Team.values().map { team -> 268 | val ship = (animState ?: state).getShip(team) 269 | val shipName = "ship_${team.name.lowercase()}" 270 | val shipPiece = createPiece(shipName) 271 | 272 | nameSpeed(state.getShip(ship.team).speed) 273 | ?.let { speed -> shipPiece.addChild("smoke_${speed}_speed") } 274 | 275 | shipPiece.addChild("coal${ship.coal}") 276 | (1..ship.passengers).forEach { passenger -> 277 | shipPiece.addPassenger(shipName, passenger) 278 | } 279 | 280 | shipPiece.rotate = ship.direction.angle.toDouble() 281 | if((animState ?: state).currentTeam == ship.team && !state.isOver) 282 | shipPiece.glow() // lower in history: if(gameModel.atLatestTurn.value == false) .5 else 1.0) 283 | if(ship.crashed) 284 | shipPiece.effect = ColorAdjust().apply { saturation = -0.8 } // SepiaTone(1.0) 285 | else 286 | shipPiece.shipBowWaveSpeed(ship.speed) 287 | addPiece(shipPiece, ship.position) 288 | } 289 | 290 | fun addLabels() { 291 | state.ships.forEach { ship -> 292 | addPiece( 293 | Label("⚙${if(state.currentTeam == ship.team && humanMove.isNotEmpty()) "${ship.movement}/" else ""}${ship.speed}") 294 | .apply { 295 | styleProperty().bind(fontSizeFromBlockSize()) 296 | this.effect = DropShadow(AppStyle.spacing, Color.BLACK) // TODO not working somehow 297 | translateY = gridSize / 10 298 | }, ship.position 299 | ) 300 | } 301 | checkHumanControls() 302 | } 303 | 304 | if(animState != null) { 305 | val ship = animState.getShip(animState.currentTeam) 306 | val piece = pieces[animState.currentTeam.index] 307 | 308 | val factors = coordinateFactors() 309 | val move = state.lastMove!! 310 | 311 | transition?.pause() 312 | transition = SequentialTransition( 313 | *move.actions.mapNotNull { action -> 314 | when(action) { 315 | is Turn -> { 316 | piece.rotate( 317 | Duration.seconds(animFactor * ship.direction.turnCountTo(action.direction).absoluteValue), 318 | Double.NaN, 319 | play = false 320 | ).apply { 321 | byAngle = ship.direction.angleTo(action.direction).toDouble() 322 | this.statusProperty().addListener { _ -> 323 | if(this.status == Animation.Status.RUNNING) { 324 | piece.shipBowWaveSpeed(0) 325 | } 326 | } 327 | } 328 | } 329 | 330 | is Advance -> { 331 | // TODO collate sequential advances 332 | val dist = action.distance 333 | val diff = ship.direction.vector * dist 334 | piece.move( 335 | Duration.seconds(animFactor * dist.toDouble().pow(0.7)), 336 | Point2D(Double.NaN, Double.NaN), 337 | play = false 338 | ) { 339 | byX = diff.x / 2.0 * factors.x 340 | byY = diff.r * factors.y 341 | this.statusProperty().addListener { _ -> 342 | if(this.status == Animation.Status.RUNNING) { 343 | piece.shipBowWaveSpeed(6) 344 | } else { 345 | piece.shipBowWaveSpeed(ship.speed) 346 | } 347 | } 348 | } 349 | } 350 | 351 | is Push -> { 352 | val diff = action.direction.vector 353 | val otherPiece = pieces[animState.otherTeam.index] 354 | otherPiece.move( 355 | Duration.seconds(animFactor / 2), 356 | Point2D(Double.NaN, Double.NaN), 357 | play = false 358 | ) { 359 | byX = diff.x / 2.0 * factors.x 360 | byY = diff.r * factors.y 361 | otherPiece.shipBowWaveSpeed(0) 362 | } 363 | } 364 | 365 | else -> null 366 | }.also { action.perform(animState) } 367 | }.toTypedArray() 368 | ).apply { 369 | setOnFinished { 370 | logger.debug { "Finished transition $it with elements ${children.joinToString()} for $move" } 371 | addLabels() 372 | pieces[animState.currentTeam.index].effect = null 373 | pieces[state.currentTeam.index].glow() 374 | Team.values().forEach { team -> 375 | val newPassengers = state.getShip(team).passengers 376 | if(animState.getShip(team).passengers < newPassengers) { 377 | pieces[team.index].addPassenger(pieces[team.index].content, newPassengers) 378 | } 379 | } 380 | } 381 | play() 382 | } 383 | } else { 384 | addLabels() 385 | } 386 | } 387 | 388 | override fun handleKeyPress(state: GameState, keyEvent: KeyEvent): Boolean { 389 | val action: Action? = when(keyEvent.code) { 390 | KeyCode.UP, KeyCode.W -> 391 | Advance(1) 392 | 393 | KeyCode.LEFT, KeyCode.A -> 394 | Turn(state.currentShip.direction - 1) 395 | 396 | KeyCode.RIGHT, KeyCode.D -> 397 | Turn(state.currentShip.direction + 1) 398 | 399 | KeyCode.BACK_SPACE, KeyCode.C, KeyCode.X -> { 400 | keyEvent.consume() 401 | cancelHumanMove() 402 | null 403 | } 404 | 405 | KeyCode.ACCEPT, KeyCode.ENTER, KeyCode.S, KeyCode.SPACE -> { 406 | keyEvent.consume() 407 | confirmHumanMove() 408 | null 409 | } 410 | 411 | else -> { 412 | keyEvent.text.toIntOrNull()?.let { 413 | if(it < CubeDirection.values().size) 414 | Push(CubeDirection.values()[it]) 415 | else null 416 | } 417 | } 418 | } 419 | 420 | logger.debug { "Adding Human Action from keypress $action" } 421 | if(action != null) { 422 | addHumanAction(action) 423 | return true 424 | } 425 | return false 426 | } 427 | 428 | 429 | override fun renderHumanControls(state: GameState) { 430 | val ship = state.currentShip 431 | addPiece(VBox().apply { 432 | translateX = -gridSize / 2 433 | translateY = gridSize / 10 434 | styleProperty().bind(fontSizeFromBlockSize(.16)) 435 | if(humanMove.all { it is Accelerate }) { 436 | val acc = (humanMove.firstOrNull() as? Accelerate)?.acc ?: 0 437 | hbox { 438 | if(ship.speed < 6 && acc > -1) 439 | button("+") { 440 | tooltip("Beschleunigen (Accelerate +1)") 441 | action { addHumanAction(Accelerate(1)) } 442 | } 443 | if(ship.speed > 1 && acc < 1) 444 | button("-") { 445 | tooltip("Bremsen (Accelerate -1)") 446 | action { addHumanAction(Accelerate(-1)) } 447 | } 448 | } 449 | } 450 | if(gameState?.mustPush != true) { 451 | if(ship.canTurn()) 452 | button("↺ A") { 453 | tooltip("Gegen den Uhrzeigersinn drehen (Turn -1") 454 | action { addHumanAction(Turn(ship.direction - 1)) } 455 | } 456 | if(ship.canAdvance()) 457 | button("→ W") { 458 | tooltip("Ein Feld vorwärts bewegen (Advance 1)") 459 | action { addHumanAction(Advance(1)) } 460 | runLater { this.requestFocus() } 461 | } 462 | if(ship.canTurn()) 463 | button("↻ D") { 464 | tooltip("Im Uhrzeigersinn drehen (Turn 1)") 465 | action { addHumanAction(Turn(ship.direction + 1)) } 466 | } 467 | } 468 | hbox { 469 | if(!isHumanMoveIncomplete()) 470 | button("✓ S") { 471 | tooltip("Zug bestätigen") 472 | action { confirmHumanMove() } 473 | } 474 | if(humanMove.isNotEmpty()) 475 | button("╳ C") { 476 | tooltip("Zug zurücksetzen") 477 | action { cancelHumanMove() } 478 | } 479 | } 480 | }, ship.position) 481 | } 482 | 483 | private fun addHumanAction(action: Action) { 484 | val state = gameState ?: return 485 | if(state.mustPush && action !is Push) { 486 | alert(Alert.AlertType.ERROR, "Abdrängaktion mit Richtung 0 (Rechts) - 5 (Oben Rechts) festlegen!") 487 | return 488 | } 489 | 490 | val newState = state.clone() 491 | val ship = newState.currentShip 492 | val extraMovement = ship.maxAcc.takeUnless { humanMove.firstOrNull() is Accelerate } ?: 0 493 | // Continual Advance on current 494 | val currentAdvance = humanMove.lastOrNull() is Advance && action is Advance && 495 | state.isCurrentShipOnCurrent() && state.board.doesFieldHaveCurrent(state.currentShip.position + state.currentShip.direction.vector * action.distance) 496 | if(currentAdvance) 497 | ship.movement++ 498 | ship.movement += extraMovement 499 | action.perform(newState)?.let { 500 | alert(Alert.AlertType.ERROR, it.message) 501 | } ?: run { 502 | ship.movement -= extraMovement 503 | if(action is Accelerate && humanMove.isNotEmpty()) 504 | humanMove[0] = humanMove[0] as Accelerate + action 505 | else 506 | humanMove.add(action) 507 | gameModel.gameState.set(newState) 508 | } 509 | } 510 | 511 | private fun cancelHumanMove() { 512 | humanMove.clear() 513 | gameModel.gameState.set(originalState) 514 | } 515 | 516 | private fun isHumanMoveIncomplete() = 517 | humanMove.none { it is Advance } || gameState?.let { state -> 518 | state.currentShip.movement > state.currentShip.freeAcc + state.currentShip.coal || 519 | humanMove.first() is Accelerate && state.currentShip.movement != 0 || 520 | state.mustPush 521 | } ?: true 522 | 523 | private fun confirmHumanMove() { 524 | if(awaitingHumanMove.value && isHumanMoveIncomplete()) { 525 | alert(Alert.AlertType.ERROR, "Unvollständiger Zug!") 526 | } else { 527 | val state = gameState ?: return 528 | if(state.currentShip.movement != 0) { 529 | humanMove.add(0, Accelerate(-state.currentShip.movement)) 530 | } 531 | if(!sendHumanMove(Move(ArrayList(humanMove)))) 532 | gameModel.gameState.set(originalState) 533 | humanMove.clear() 534 | } 535 | } 536 | 537 | private fun createPiece(type: String): PieceImage = 538 | PieceImage(calculatedBlockSize, type) 539 | 540 | private fun addPiece(node: T, coordinates: CubeCoordinates): T { 541 | if(grid.children.contains(node)) 542 | logger.warn { "Attempting to add duplicate grid child at $coordinates: $node" } 543 | else 544 | grid.add(node) 545 | calculatedBlockSize.listenImmediately { 546 | val size = coordinateFactors(it.toDouble()) 547 | node.anchorpaneConstraints { 548 | val state = gameState ?: return@anchorpaneConstraints 549 | val bounds = state.visibleBoard().bounds 550 | leftAnchor = (coordinates.x / 2.0 - bounds.first.first + .5) * size.x 551 | topAnchor = (coordinates.r - bounds.second.first - .5) * size.y 552 | logger.trace { "$coordinates: $node at ${leftAnchor?.toInt()},${topAnchor?.toInt()} within $bounds" } 553 | } 554 | } 555 | return node 556 | } 557 | 558 | fun coordinateFactors(size: Double = calculatedBlockSize.value) = Point2D(size * .774, size * .668) 559 | } 560 | --------------------------------------------------------------------------------