├── 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 | ##
Grafischer Spieleserver der Software-Challenge Germany 
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 |
--------------------------------------------------------------------------------