summaryrefslogtreecommitdiff
path: root/sw-ui/src/jsMain/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'sw-ui/src/jsMain/kotlin')
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt49
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/Application.kt52
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt48
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/errors/ErrorDialog.kt57
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt227
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/BoardSummary.kt211
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt78
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt310
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameStyles.kt86
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt276
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/HandRotationIndicator.kt56
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PlayerPreparedCardPresenter.kt80
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PreparedMove.kt73
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/ScoreTable.kt188
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Tokens.kt155
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/TransactionsSelector.kt265
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt58
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt69
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt213
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt105
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt65
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt22
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt15
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt272
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/LobbyStyles.kt20
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt117
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt57
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt139
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Table.kt97
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/names/RandomNameGenerator.kt546
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt32
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt34
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt95
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt29
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt31
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt44
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt131
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt106
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/router/Router.kt48
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt15
-rw-r--r--sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/StyleUtils.kt43
-rw-r--r--sw-ui/src/jsMain/kotlin/webpack/WebpackUtils.kt9
42 files changed, 4623 insertions, 0 deletions
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
new file mode 100644
index 00000000..44066b49
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
@@ -0,0 +1,49 @@
+package org.luxons.sevenwonders.ui
+
+import kotlinx.browser.window
+import kotlinx.coroutines.*
+import org.luxons.sevenwonders.ui.components.*
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.redux.sagas.*
+import react.*
+import react.dom.client.*
+import react.redux.*
+import redux.*
+import web.dom.document
+import web.html.*
+
+fun main() {
+ window.onload = { init() }
+}
+
+private fun init() {
+ val rootElement = document.getElementById("root")
+ if (rootElement == null) {
+ console.error("Element with ID 'root' was not found, cannot bootstrap react app")
+ return
+ }
+ renderRoot(rootElement)
+}
+
+private fun renderRoot(rootElement: HTMLElement) {
+ val store = initRedux()
+ val connectedApp = StrictMode.create {
+ Provider {
+ this.store = store
+ Application()
+ }
+ }
+ createRoot(rootElement).render(connectedApp)
+}
+
+@OptIn(DelicateCoroutinesApi::class)
+private fun initRedux(): Store<SwState, RAction, WrapperAction> {
+ val sagaManager = SagaManager<SwState, RAction, WrapperAction>()
+ val store = configureStore(sagaManager = sagaManager)
+ GlobalScope.launch {
+ sagaManager.launchSaga(this) {
+ rootSaga()
+ }
+ }
+ return store
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/Application.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/Application.kt
new file mode 100644
index 00000000..2cf8b4f1
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/Application.kt
@@ -0,0 +1,52 @@
+package org.luxons.sevenwonders.ui.components
+
+import js.core.jso
+import org.luxons.sevenwonders.ui.components.errors.*
+import org.luxons.sevenwonders.ui.components.game.*
+import org.luxons.sevenwonders.ui.components.gameBrowser.*
+import org.luxons.sevenwonders.ui.components.home.*
+import org.luxons.sevenwonders.ui.components.lobby.*
+import org.luxons.sevenwonders.ui.router.*
+import react.*
+import react.router.*
+import react.router.dom.*
+
+val Application = FC("Application") {
+ ErrorDialog()
+ RouterProvider {
+ router = hashRouter
+ }
+}
+
+// Using plain jso objects instead of createRoutesFromElements
+// because of a broken Route external interface (no properties)
+// See https://github.com/JetBrains/kotlin-wrappers/issues/2024
+private val hashRouter = createHashRouter(
+ routes = arrayOf(
+ jso {
+ path = SwRoute.GAME_BROWSER.path
+ Component = GameBrowser
+ },
+ jso {
+ path = SwRoute.GAME.path
+ Component = GameScene
+ },
+ jso {
+ path = SwRoute.LOBBY.path
+ Component = Lobby
+ },
+ jso {
+ path = SwRoute.HOME.path
+ Component = Home
+ },
+ jso {
+ path = "*"
+ Component = FC {
+ Navigate {
+ to = "/"
+ replace = true
+ }
+ }
+ },
+ ),
+)
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt
new file mode 100644
index 00000000..ee9c17ab
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt
@@ -0,0 +1,48 @@
+package org.luxons.sevenwonders.ui.components
+
+import emotion.css.*
+import org.luxons.sevenwonders.ui.utils.*
+import web.cssom.*
+
+
+object GlobalStyles {
+
+ val preGameWidth = 60.rem
+
+ val zeusBackground = ClassName {
+ background = "url('images/backgrounds/zeus-temple.jpg') center no-repeat".unsafeCast<Background>()
+ backgroundSize = BackgroundSize.cover
+ }
+
+ val fullscreen = ClassName {
+ position = Position.fixed
+ top = 0.px
+ left = 0.px
+ bottom = 0.px
+ right = 0.px
+ overflow = Overflow.hidden
+ }
+
+ val papyrusBackground = ClassName {
+ background = "url('images/backgrounds/papyrus.jpg')".unsafeCast<Background>()
+ backgroundSize = BackgroundSize.cover
+ }
+
+ val centerLeftTopTransform = ClassName {
+ left = 50.pct
+ top = 50.pct
+ transform = translate((-50).pct, (-50).pct)
+ }
+
+ val fixedCenter = ClassName(centerLeftTopTransform) {
+ position = Position.fixed
+ }
+
+ val centerInPositionedParent = ClassName(centerLeftTopTransform) {
+ position = Position.absolute
+ }
+
+ val noPadding = ClassName {
+ padding = Padding(all = 0.px)
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/errors/ErrorDialog.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/errors/ErrorDialog.kt
new file mode 100644
index 00000000..c728d405
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/errors/ErrorDialog.kt
@@ -0,0 +1,57 @@
+package org.luxons.sevenwonders.ui.components.errors
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import kotlinx.browser.*
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.router.*
+import react.*
+import react.dom.html.ReactHTML.p
+import react.redux.*
+import redux.*
+
+val ErrorDialog = FC {
+ val dispatch = useDispatch<RAction, WrapperAction>()
+
+ ErrorDialogPresenter {
+ errorMessage = useSwSelector { it.fatalError }
+ goHome = { dispatch(Navigate(SwRoute.HOME)) }
+ }
+}
+
+private external interface ErrorDialogProps : Props {
+ var errorMessage: String?
+ var goHome: () -> Unit
+}
+
+private val ErrorDialogPresenter = FC<ErrorDialogProps>("ErrorDialogPresenter") { props ->
+ val errorMessage = props.errorMessage
+ BpDialog {
+ isOpen = errorMessage != null
+ titleText = "Oops!"
+ icon = BpIcon.create {
+ icon = IconNames.ERROR
+ intent = Intent.DANGER
+ }
+ onClose = { goHomeAndRefresh() }
+
+ BpDialogBody {
+ p {
+ +(errorMessage ?: "fatal error")
+ }
+ }
+ BpDialogFooter {
+ BpButton {
+ icon = IconNames.LOG_OUT
+ onClick = { goHomeAndRefresh() }
+
+ +"HOME"
+ }
+ }
+ }
+}
+
+private fun goHomeAndRefresh() {
+ // we don't use a redux action here because we actually want to redirect and refresh the page
+ window.location.href = SwRoute.HOME.path
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt
new file mode 100644
index 00000000..1eb5f6f0
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt
@@ -0,0 +1,227 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.boards.*
+import org.luxons.sevenwonders.model.cards.*
+import org.luxons.sevenwonders.model.wonders.*
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+import web.cssom.*
+import web.html.*
+
+// card offsets in % of their size when displayed in columns
+private const val xOffset = 20
+private const val yOffset = 21
+
+external interface BoardComponentProps : PropsWithClassName {
+ var board: Board
+}
+
+val BoardComponent = FC<BoardComponentProps>("Board") { props ->
+ div {
+ className = props.className
+ tableCards(cardColumns = props.board.playedCards)
+ wonderComponent(wonder = props.board.wonder, military = props.board.military)
+ }
+}
+
+private fun ChildrenBuilder.tableCards(cardColumns: List<List<TableCard>>) {
+ div {
+ css {
+ display = Display.flex
+ justifyContent = JustifyContent.spaceAround
+ height = 45.pct
+ width = 100.pct
+ }
+ cardColumns.forEach { cards ->
+ TableCardColumn {
+ this.key = cards.first().color.toString()
+ this.cards = cards
+ }
+ }
+ }
+}
+
+private external interface TableCardColumnProps : PropsWithClassName {
+ var cards: List<TableCard>
+}
+
+private val TableCardColumn = FC<TableCardColumnProps>("TableCardColumn") { props ->
+ div {
+ css {
+ height = 100.pct
+ width = 13.pct
+ marginRight = 4.pct
+ position = Position.relative
+ }
+ props.cards.forEachIndexed { index, card ->
+ TableCard {
+ this.card = card
+ this.indexInColumn = index
+ this.key = card.name
+ }
+ }
+ }
+}
+
+private external interface TableCardProps : PropsWithClassName {
+ var card: TableCard
+ var indexInColumn: Int
+}
+
+private val TableCard = FC<TableCardProps>("TableCard") { props ->
+ val highlightColor = if (props.card.playedDuringLastMove) NamedColor.gold else null
+ CardImage {
+ this.card = props.card
+ this.highlightColor = highlightColor
+
+ css {
+ position = Position.absolute
+ zIndex = integer(props.indexInColumn + 2) // go above the board and the built wonder cards
+ transform = translate(
+ tx = (props.indexInColumn * xOffset).pct,
+ ty = (props.indexInColumn * yOffset).pct,
+ )
+ maxWidth = 100.pct
+ maxHeight = 70.pct
+
+ hover {
+ zIndex = integer(1000)
+ maxWidth = 110.pct
+ maxHeight = 75.pct
+ hoverHighlightStyle()
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.wonderComponent(wonder: ApiWonder, military: Military) {
+ div {
+ css {
+ position = Position.relative
+ width = 100.pct
+ height = 40.pct
+ }
+ div {
+ css {
+ position = Position.absolute
+ left = 50.pct
+ top = 0.px
+ transform = translatex((-50).pct)
+ height = 100.pct
+ maxWidth = 95.pct // same as wonder
+
+ // bring to the foreground on hover
+ hover { zIndex = integer(1000) }
+ }
+ img {
+ src = "/images/wonders/${wonder.image}"
+ title = wonder.name
+ alt = "Wonder ${wonder.name}"
+
+ css {
+ borderRadius = "0.5%/1.5%".unsafeCast<BorderRadius>()
+ boxShadow = BoxShadow(color = NamedColor.black, offsetX = 0.2.rem, offsetY = 0.2.rem, blurRadius = 0.5.rem)
+ maxHeight = 100.pct
+ maxWidth = 100.pct
+ zIndex = integer(1) // go above the built wonder cards, but below the table cards
+
+ hover { hoverHighlightStyle() }
+ }
+ }
+ div {
+ css {
+ position = Position.absolute
+ top = 20.pct
+ right = (-80).px
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.start
+ }
+ victoryPoints(military.victoryPoints) {
+ css {
+ marginBottom = 5.px
+ }
+ }
+ defeatTokenCount(military.nbDefeatTokens) {
+ css {
+ marginTop = 5.px
+ }
+ }
+ }
+ wonder.stages.forEachIndexed { index, stage ->
+ WonderStageElement {
+ this.stage = stage
+ css {
+ wonderCardStyle(index, wonder.stages.size)
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.victoryPoints(points: Int, block: HTMLAttributes<HTMLDivElement>.() -> Unit = {}) {
+ boardToken("military/victory1", points, block)
+}
+
+private fun ChildrenBuilder.defeatTokenCount(nbDefeatTokens: Int, block: HTMLAttributes<HTMLDivElement>.() -> Unit = {}) {
+ boardToken("military/defeat1", nbDefeatTokens, block)
+}
+
+private fun ChildrenBuilder.boardToken(tokenName: String, count: Int, block: HTMLAttributes<HTMLDivElement>.() -> Unit) {
+ tokenWithCount(
+ tokenName = tokenName,
+ count = count,
+ countPosition = TokenCountPosition.RIGHT,
+ brightText = true,
+ ) {
+ css {
+ filter = dropShadow(0.2.rem, 0.2.rem, 0.5.rem, NamedColor.black)
+ height = 15.pct
+ }
+ block()
+ }
+}
+
+private external interface WonderStageElementProps : PropsWithClassName {
+ var stage: ApiWonderStage
+}
+
+private val WonderStageElement = FC<WonderStageElementProps>("WonderStageElement") { props ->
+ val back = props.stage.cardBack
+ if (back != null) {
+ val highlightColor = if (props.stage.builtDuringLastMove) NamedColor.gold else null
+ CardBackImage {
+ this.cardBack = back
+ this.highlightColor = highlightColor
+ this.className = props.className
+ }
+ } else {
+ CardPlaceholderImage {
+ this.className = props.className
+ }
+ }
+}
+
+private fun PropertiesBuilder.wonderCardStyle(stageIndex: Int, nbStages: Int) {
+ position = Position.absolute
+ top = 60.pct // makes the cards stick out of the bottom of the wonder
+ left = stagePositionPercent(stageIndex, nbStages).pct
+ maxWidth = 24.pct // ratio of card width to wonder width
+ maxHeight = 90.pct // ratio of card height to wonder height
+ zIndex = integer(-1) // below wonder (somehow 0 is not sufficient)
+}
+
+private fun stagePositionPercent(stageIndex: Int, nbStages: Int): Double = when (nbStages) {
+ 2 -> 37.5 + stageIndex * 29.8 // 37.5 (29.8) 67.3
+ 4 -> -1.5 + stageIndex * 26.7 // -1.5 (26.6) 25.1 (26.8) 51.9 (26.7) 78.6
+ else -> 7.9 + stageIndex * 30.0
+}
+
+private fun PropertiesBuilder.hoverHighlightStyle() {
+ highlightStyle(NamedColor.palegoldenrod)
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/BoardSummary.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/BoardSummary.kt
new file mode 100644
index 00000000..37de113c
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/BoardSummary.kt
@@ -0,0 +1,211 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import csstype.*
+import emotion.css.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.boards.*
+import org.luxons.sevenwonders.ui.components.gameBrowser.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.hr
+import web.cssom.*
+
+enum class BoardSummarySide(
+ val tokenCountPosition: TokenCountPosition,
+ val alignment: AlignItems,
+ val popoverPosition: PopoverPosition,
+) {
+ LEFT(TokenCountPosition.RIGHT, AlignItems.flexStart, PopoverPosition.RIGHT),
+ TOP(TokenCountPosition.OVER, AlignItems.flexStart, PopoverPosition.BOTTOM),
+ RIGHT(TokenCountPosition.LEFT, AlignItems.flexEnd, PopoverPosition.LEFT),
+ BOTTOM(TokenCountPosition.OVER, AlignItems.flexStart, PopoverPosition.TOP),
+}
+
+external interface BoardSummaryWithPopoverProps : PropsWithClassName {
+ var player: PlayerDTO
+ var board: Board
+ var side: BoardSummarySide
+}
+
+val BoardSummaryWithPopover = FC<BoardSummaryWithPopoverProps>("BoardSummaryWithPopover") { props ->
+ BpPopover {
+ content = BoardComponent.create {
+ className = GameStyles.fullBoardPreview
+ board = props.board
+ }
+ position = props.side.popoverPosition
+ interactionKind = PopoverInteractionKind.HOVER
+ popoverClassName = ClassName {
+ val bgColor = GameStyles.sandBgColor.withAlpha(0.7)
+ backgroundColor = bgColor
+ borderRadius = 0.5.rem
+ padding = Padding(all = 0.5.rem)
+
+ children(".bp4-popover-content") {
+ background = None.none // overrides default white background
+ }
+ descendants(".bp4-popover-arrow-fill") {
+ set(Variable("fill"), bgColor.toString()) // overrides default white arrow
+ }
+ descendants(".bp4-popover-arrow::before") {
+ // The popover arrow is implemented with a simple square rotated 45 degrees (like a rhombus).
+ // Since we use a semi-transparent background, we can see the box shadow of the rest of the arrow through
+ // the popover, and thus we see the square. This boxShadow(transparent) is to avoid that.
+ boxShadow = BoxShadow(0.px, 0.px, 0.px, 0.px, NamedColor.transparent)
+ }
+ }.toString()
+
+ BoardSummary {
+ this.className = props.className
+ this.player = props.player
+ this.board = props.board
+ this.side = props.side
+ }
+ }
+}
+
+external interface BoardSummaryProps : PropsWithClassName {
+ var player: PlayerDTO
+ var board: Board
+ var side: BoardSummarySide
+ var showPreparationStatus: Boolean?
+}
+
+val BoardSummary = FC<BoardSummaryProps>("BoardSummary") { props ->
+ div {
+ css(props.className) {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = props.side.alignment
+ padding = Padding(all = 0.5.rem)
+ backgroundColor = NamedColor.palegoldenrod.withAlpha(0.5)
+ zIndex = integer(50) // above table cards
+
+ hover {
+ backgroundColor = NamedColor.palegoldenrod
+ }
+ }
+
+ val showPreparationStatus = props.showPreparationStatus ?: true
+ topBar(props.player, props.side, showPreparationStatus)
+ hr {
+ css {
+ margin = Margin(vertical = 0.5.rem, horizontal = 0.rem)
+ width = 100.pct
+ }
+ }
+ bottomBar(props.side, props.board, props.player, showPreparationStatus)
+ }
+}
+
+private fun ChildrenBuilder.topBar(player: PlayerDTO, side: BoardSummarySide, showPreparationStatus: Boolean) {
+ val playerIconSize = 25
+ if (showPreparationStatus && side == BoardSummarySide.TOP) {
+ div {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ justifyContent = JustifyContent.spaceBetween
+ width = 100.pct
+ }
+ PlayerInfo {
+ this.player = player
+ this.iconSize = playerIconSize
+ }
+ PlayerPreparedCard {
+ this.playerDisplayName = player.displayName
+ this.username = player.username
+ }
+ }
+ } else {
+ PlayerInfo {
+ this.player = player
+ this.iconSize = playerIconSize
+ }
+ }
+}
+
+private fun ChildrenBuilder.bottomBar(side: BoardSummarySide, board: Board, player: PlayerDTO, showPreparationStatus: Boolean) {
+ div {
+ css {
+ display = Display.flex
+ flexDirection = if (side == BoardSummarySide.TOP || side == BoardSummarySide.BOTTOM) FlexDirection.row else FlexDirection.column
+ alignItems = side.alignment
+ if (side != BoardSummarySide.TOP) {
+ width = 100.pct
+ }
+ }
+ val tokenSize = 2.rem
+ generalCounts(board, tokenSize, side.tokenCountPosition)
+ BpDivider()
+ scienceTokens(board, tokenSize, side.tokenCountPosition)
+ if (showPreparationStatus && side != BoardSummarySide.TOP) {
+ BpDivider()
+ div {
+ css {
+ width = 100.pct
+ alignItems = AlignItems.center
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ }
+ PlayerPreparedCard {
+ this.playerDisplayName = player.displayName
+ this.username = player.username
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.generalCounts(
+ board: Board,
+ tokenSize: Length,
+ countPosition: TokenCountPosition,
+) {
+ goldIndicator(amount = board.gold, imgSize = tokenSize, amountPosition = countPosition)
+ tokenWithCount(
+ tokenName = "laurel-blue",
+ count = board.bluePoints,
+ imgSize = tokenSize,
+ countPosition = countPosition,
+ brightText = countPosition == TokenCountPosition.OVER,
+ )
+ tokenWithCount(
+ tokenName = "military/shield",
+ count = board.military.nbShields,
+ imgSize = tokenSize,
+ countPosition = countPosition,
+ brightText = countPosition == TokenCountPosition.OVER,
+ )
+}
+
+private fun ChildrenBuilder.scienceTokens(
+ board: Board,
+ tokenSize: Length,
+ sciencePosition: TokenCountPosition,
+) {
+ tokenWithCount(
+ tokenName = "science/compass",
+ count = board.science.nbCompasses,
+ imgSize = tokenSize,
+ countPosition = sciencePosition,
+ brightText = sciencePosition == TokenCountPosition.OVER,
+ )
+ tokenWithCount(
+ tokenName = "science/cog",
+ count = board.science.nbWheels,
+ imgSize = tokenSize,
+ countPosition = sciencePosition,
+ brightText = sciencePosition == TokenCountPosition.OVER,
+ )
+ tokenWithCount(
+ tokenName = "science/tablet",
+ count = board.science.nbTablets,
+ imgSize = tokenSize,
+ countPosition = sciencePosition,
+ brightText = sciencePosition == TokenCountPosition.OVER,
+ )
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt
new file mode 100644
index 00000000..cffd509f
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt
@@ -0,0 +1,78 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.cards.*
+import react.*
+import react.dom.html.ReactHTML.img
+import web.cssom.*
+import web.cssom.Color
+
+external interface CardImageProps : PropsWithClassName {
+ var card: Card
+ var faceDown: Boolean?
+ var highlightColor: Color?
+}
+
+val CardImage = FC<CardImageProps>("CardImage") { props ->
+ if (props.faceDown == true) {
+ CardBackImage {
+ cardBack = props.card.back
+ highlightColor = props.highlightColor
+ }
+ } else {
+ img {
+ src = "/images/cards/${props.card.image}"
+ title = props.card.name
+ alt = "Card ${props.card.name}"
+
+ css(props.className) {
+ cardImageStyle(props.highlightColor)
+ }
+ }
+ }
+}
+
+external interface CardBackImageProps : PropsWithClassName {
+ var cardBack: CardBack
+ var highlightColor: Color?
+}
+
+val CardBackImage = FC<CardBackImageProps>("CardBackImage") { props ->
+ img {
+ src = "/images/cards/back/${props.cardBack.image}"
+ alt = "Card back (${props.cardBack.image})"
+ css(props.className) {
+ cardImageStyle(props.highlightColor)
+ }
+ }
+}
+
+val CardPlaceholderImage = FC<PropsWithClassName>("CardPlaceholderImage") { props ->
+ img {
+ src = "/images/cards/back/placeholder.png"
+ alt = "Card placeholder"
+ css(props.className) {
+ opacity = number(0.20)
+ borderRadius = 5.pct
+ }
+ }
+}
+
+private fun PropertiesBuilder.cardImageStyle(highlightColor: Color?) {
+ borderRadius = 5.pct
+ boxShadow = BoxShadow(offsetX = 2.px, offsetY = 2.px, blurRadius = 5.px, color = NamedColor.black)
+ highlightStyle(highlightColor)
+}
+
+internal fun PropertiesBuilder.highlightStyle(highlightColor: Color?) {
+ if (highlightColor != null) {
+ boxShadow = BoxShadow(
+ offsetX = 0.px,
+ offsetY = 0.px,
+ blurRadius = 1.rem,
+ spreadRadius = 0.1.rem,
+ color = highlightColor,
+ )
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt
new file mode 100644
index 00000000..622e3f6d
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt
@@ -0,0 +1,310 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import emotion.react.*
+import org.luxons.sevenwonders.client.*
+import org.luxons.sevenwonders.model.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.boards.*
+import org.luxons.sevenwonders.model.cards.*
+import org.luxons.sevenwonders.model.resources.*
+import org.luxons.sevenwonders.ui.components.*
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.utils.*
+import org.luxons.sevenwonders.ui.utils.Padding
+import react.*
+import react.dom.html.ReactHTML.div
+import web.cssom.*
+import web.cssom.Position
+
+external interface GameSceneProps : Props {
+ var currentPlayer: PlayerDTO?
+ var players: List<PlayerDTO>
+ var game: GameState
+ var preparedMove: PlayerMove?
+ var preparedCard: HandCard?
+ var sayReady: () -> Unit
+ var prepareMove: (move: PlayerMove) -> Unit
+ var unprepareMove: () -> Unit
+ var leaveGame: () -> Unit
+}
+
+data class TransactionSelectorState(
+ val moveType: MoveType,
+ val card: HandCard,
+ val transactionsOptions: ResourceTransactionOptions,
+)
+
+val GameScene = FC("GameScene") {
+
+ val player = useSwSelector { it.currentPlayer }
+ val gameState = useSwSelector { it.gameState }
+ val dispatch = useSwDispatch()
+
+ div {
+ css(GlobalStyles.papyrusBackground, GlobalStyles.fullscreen) {}
+
+ if (gameState == null) {
+ BpNonIdealState {
+ icon = IconNames.ERROR
+ titleText = "Error: no game data"
+ }
+ } else {
+ GameScenePresenter {
+ currentPlayer = player
+ players = gameState.players
+ game = gameState
+ preparedMove = gameState.currentPreparedMove
+ preparedCard = gameState.currentPreparedCard
+
+ prepareMove = { move -> dispatch(RequestPrepareMove(move)) }
+ unprepareMove = { dispatch(RequestUnprepareMove()) }
+ sayReady = { dispatch(RequestSayReady()) }
+ leaveGame = { dispatch(RequestLeaveGame()) }
+ }
+ }
+ }
+}
+
+private val GameScenePresenter = FC<GameSceneProps>("GameScenePresenter") { props ->
+ var transactionSelectorState by useState<TransactionSelectorState>()
+
+ val game = props.game
+ val board = game.getOwnBoard()
+ div {
+ val maybeRed = GameStyles.pulsatingRed.takeIf { game.everyoneIsWaitingForMe() }
+ css(maybeRed) {
+ height = 100.pct
+ }
+ val action = game.action
+ if (action is TurnAction.WatchScore) {
+ scoreTableOverlay(action.scoreBoard, props.players, props.leaveGame)
+ }
+ actionInfo(game.action.message)
+ BoardComponent {
+ this.board = board
+ css {
+ padding = Padding(vertical = 7.rem, horizontal = 7.rem) // to fit the action info message & board summaries
+ width = 100.pct
+ height = 100.pct
+ }
+ }
+ transactionsSelectorDialog(
+ state = transactionSelectorState,
+ neighbours = playerNeighbours(props.currentPlayer, props.players),
+ prepareMove = { move ->
+ props.prepareMove(move)
+ transactionSelectorState = null
+ },
+ cancelTransactionSelection = { transactionSelectorState = null },
+ )
+ boardSummaries(game)
+ handRotationIndicator(game.handRotationDirection)
+ handCards(
+ game = game,
+ prepareMove = props.prepareMove,
+ startTransactionsSelection = {
+ transactionSelectorState = it
+ }
+ )
+ val card = props.preparedCard
+ val move = props.preparedMove
+ if (card != null && move != null) {
+ BpOverlay {
+ isOpen = true
+ onClose = { props.unprepareMove() }
+
+ preparedMove(card, move, props.unprepareMove) {
+ css(GlobalStyles.fixedCenter) {}
+ }
+ }
+ }
+ if (game.action is TurnAction.SayReady) {
+ SayReadyButton {
+ currentPlayer = props.currentPlayer
+ players = props.players
+ sayReady = props.sayReady
+ }
+ }
+ }
+}
+
+private fun GameState.everyoneIsWaitingForMe(): Boolean {
+ val onlyMeInTheGame = players.count { it.isHuman } == 1
+ if (onlyMeInTheGame || currentPreparedMove != null) {
+ return false
+ }
+ return preparedCardsByUsername.values.count { it != null } == players.size - 1
+}
+
+private fun playerNeighbours(currentPlayer: PlayerDTO?, players: List<PlayerDTO>): Pair<PlayerDTO, PlayerDTO> {
+ val me = currentPlayer?.username ?: error("we shouldn't be trying to display this if there is no player")
+ val size = players.size
+ val myIndex = players.indexOfFirst { it.username == me }
+ return players[(myIndex - 1 + size) % size] to players[(myIndex + 1) % size]
+}
+
+private fun ChildrenBuilder.actionInfo(message: String) {
+ div {
+ css(ClassName(Classes.DARK)) {
+ position = Position.fixed
+ top = 0.pct
+ left = 0.pct
+ margin = Margin(vertical = 0.4.rem, horizontal = 0.4.rem)
+ maxWidth = 25.pct // leave space for 4 board summaries when there are 7 players
+ }
+ BpCard {
+ elevation = Elevation.TWO
+ css {
+ padding = Padding(all = 0.px)
+ }
+
+ BpCallout {
+ intent = Intent.PRIMARY
+ icon = IconNames.INFO_SIGN
+ +message
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.boardSummaries(game: GameState) {
+ val leftBoard = game.getBoard(RelativeBoardPosition.LEFT)
+ val rightBoard = game.getBoard(RelativeBoardPosition.RIGHT)
+ val topBoards = game.getNonNeighbourBoards().reversed()
+ selfBoardSummary(game.getOwnBoard(), game.players)
+ leftPlayerBoardSummary(leftBoard, game.players)
+ rightPlayerBoardSummary(rightBoard, game.players)
+ if (topBoards.isNotEmpty()) {
+ topPlayerBoardsSummaries(topBoards, game.players)
+ }
+}
+
+private fun ChildrenBuilder.leftPlayerBoardSummary(board: Board, players: List<PlayerDTO>) {
+ div {
+ css {
+ position = Position.absolute
+ left = 0.px
+ bottom = 40.pct
+ }
+ BoardSummaryWithPopover {
+ this.player = players[board.playerIndex]
+ this.board = board
+ this.side = BoardSummarySide.LEFT
+
+ css {
+ borderTopRightRadius = 0.4.rem
+ borderBottomRightRadius = 0.4.rem
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.rightPlayerBoardSummary(board: Board, players: List<PlayerDTO>) {
+ div {
+ css {
+ position = Position.absolute
+ right = 0.px
+ bottom = 40.pct
+ }
+ BoardSummaryWithPopover {
+ this.player = players[board.playerIndex]
+ this.board = board
+ this.side = BoardSummarySide.RIGHT
+
+ css {
+ borderTopLeftRadius = 0.4.rem
+ borderBottomLeftRadius = 0.4.rem
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.topPlayerBoardsSummaries(boards: List<Board>, players: List<PlayerDTO>) {
+ div {
+ css {
+ position = Position.absolute
+ top = 0.px
+ left = 50.pct
+ transform = translate((-50).pct)
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ }
+ boards.forEach { board ->
+ BoardSummaryWithPopover {
+ this.player = players[board.playerIndex]
+ this.board = board
+ this.side = BoardSummarySide.TOP
+
+ css {
+ borderBottomLeftRadius = 0.4.rem
+ borderBottomRightRadius = 0.4.rem
+ margin = Margin(vertical = 0.rem, horizontal = 2.rem)
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.selfBoardSummary(board: Board, players: List<PlayerDTO>) {
+ div {
+ css {
+ position = Position.absolute
+ bottom = 0.px
+ left = 0.px
+ }
+ BoardSummary {
+ this.player = players[board.playerIndex]
+ this.board = board
+ this.side = BoardSummarySide.BOTTOM
+ this.showPreparationStatus = false
+
+ css {
+ borderTopLeftRadius = 0.4.rem
+ borderTopRightRadius = 0.4.rem
+ margin = Margin(vertical = 0.rem, horizontal = 2.rem)
+ }
+ }
+ }
+}
+
+private external interface SayReadyButtonProps : Props {
+ var currentPlayer: PlayerDTO?
+ var players: List<PlayerDTO>
+ var sayReady: () -> Unit
+}
+
+private val SayReadyButton = FC<SayReadyButtonProps>("SayReadyButton") { props ->
+ val isReady = props.currentPlayer?.isReady == true
+ val intent = if (isReady) Intent.SUCCESS else Intent.PRIMARY
+ div {
+ css {
+ position = Position.absolute
+ bottom = 6.rem
+ left = 50.pct
+ transform = translate(tx = (-50).pct)
+ zIndex = integer(2) // go above the wonder (1) and wonder-upgrade cards (0)
+ }
+ BpButtonGroup {
+ BpButton {
+ this.large = true
+ this.disabled = isReady
+ this.intent = intent
+ this.icon = if (isReady) IconNames.TICK_CIRCLE else IconNames.PLAY
+ this.onClick = { props.sayReady() }
+
+ +"READY"
+ }
+ // not really a button, but nice for style
+ BpButton {
+ this.large = true
+ this.icon = IconNames.PEOPLE
+ this.disabled = isReady
+ this.intent = intent
+
+ +"${props.players.count { it.isReady }}/${props.players.size}"
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameStyles.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameStyles.kt
new file mode 100644
index 00000000..f5ec475e
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/GameStyles.kt
@@ -0,0 +1,86 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import emotion.css.*
+import org.luxons.sevenwonders.ui.utils.*
+import web.cssom.*
+
+object GameStyles {
+
+ val totalScore = ClassName {
+ fontWeight = FontWeight.bold
+ }
+
+ val civilScore = scoreTagColorCss(Color("#2a73c9"))
+ val scienceScore = scoreTagColorCss(Color("#0f9960"))
+ val militaryScore = scoreTagColorCss(Color("#d03232"))
+ val tradeScore = scoreTagColorCss(Color("#e2c11b"))
+ val guildScore = scoreTagColorCss(Color("#663399"))
+ val wonderScore = scoreTagColorCss(NamedColor.darkcyan)
+ val goldScore = scoreTagColorCss(NamedColor.goldenrod)
+
+ val sandBgColor = NamedColor.palegoldenrod
+
+
+ val fullBoardPreview = ClassName {
+ width = 40.vw
+ height = 50.vh
+ }
+
+ val dimmedCard = ClassName {
+ filter = brightness(60.pct) + grayscale(50.pct)
+ }
+
+ val transactionsSelector = ClassName {
+ backgroundColor = sandBgColor
+ width = 40.rem // default is 500px, we want to fit players on the side
+
+ children(".bp4-dialog-header") {
+ background = None.none // overrides default white background
+ }
+ }
+
+ val bestPrice = ClassName {
+ fontWeight = FontWeight.bold
+ color = rgb(50, 120, 50)
+ transform = rotate((-20).deg)
+ }
+
+ val discardMoveText = ClassName {
+ display = Display.flex
+ alignItems = AlignItems.center
+ height = 3.rem
+ fontSize = 2.rem
+ color = NamedColor.goldenrod
+ fontWeight = FontWeight.bold
+ borderTop = Border(0.2.rem, LineStyle.solid, NamedColor.goldenrod)
+ borderBottom = Border(0.2.rem, LineStyle.solid, NamedColor.goldenrod)
+ }
+
+ val scoreBoard = ClassName {
+ backgroundColor = sandBgColor
+ }
+
+ private fun scoreTagColorCss(color: Color) = ClassName {
+ backgroundColor = color
+ }
+
+ val pulsatingRed = ClassName {
+ animation = Animation(
+ name = keyframes {
+ to {
+ boxShadow = BoxShadow(
+ inset = BoxShadowInset.inset,
+ offsetX = 0.px,
+ offsetY = 0.px,
+ blurRadius = 20.px,
+ spreadRadius = 8.px,
+ color = NamedColor.red,
+ )
+ }
+ },
+ duration = 2.s,
+ )
+ animationIterationCount = AnimationIterationCount.infinite
+ animationDirection = AnimationDirection.alternate
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt
new file mode 100644
index 00000000..da71ea0b
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt
@@ -0,0 +1,276 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.client.*
+import org.luxons.sevenwonders.model.*
+import org.luxons.sevenwonders.model.boards.*
+import org.luxons.sevenwonders.model.cards.*
+import org.luxons.sevenwonders.model.resources.*
+import org.luxons.sevenwonders.model.wonders.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.div
+import web.cssom.*
+import web.cssom.Position
+import kotlin.math.*
+
+fun ChildrenBuilder.handCards(
+ game: GameState,
+ prepareMove: (PlayerMove) -> Unit,
+ startTransactionsSelection: (TransactionSelectorState) -> Unit,
+) {
+ HandCards {
+ this.action = game.action
+ this.ownBoard = game.getOwnBoard()
+ this.preparedMove = game.currentPreparedMove
+ this.prepareMove = { moveType: MoveType, card: HandCard, transactionOptions: ResourceTransactionOptions ->
+ when (transactionOptions.size) {
+ 1 -> prepareMove(PlayerMove(moveType, card.name, transactionOptions.single()))
+ else -> startTransactionsSelection(TransactionSelectorState(moveType, card, transactionOptions))
+ }
+ }
+ }
+}
+
+private enum class HandAction(
+ val buttonTitle: String,
+ val moveType: MoveType,
+ val icon: IconName,
+) {
+ PLAY("PLAY", MoveType.PLAY, "play"),
+ PLAY_FREE("Play as this age's free card", MoveType.PLAY_FREE, "star"),
+ PLAY_FREE_DISCARDED("Play discarded card", MoveType.PLAY_FREE_DISCARDED, "star"),
+ COPY_GUILD("Copy this guild card", MoveType.COPY_GUILD, "duplicate")
+}
+
+external interface HandCardsProps : Props {
+ var action: TurnAction
+ var ownBoard: Board
+ var preparedMove: PlayerMove?
+ var prepareMove: (MoveType, HandCard, ResourceTransactionOptions) -> Unit
+}
+
+private val HandCards = FC<HandCardsProps>("HandCards") { props ->
+ val hand = props.action.cardsToPlay() ?: return@FC
+ div {
+ css {
+ handStyle()
+ }
+ hand.filter { it.name != props.preparedMove?.cardName }.forEachIndexed { index, c ->
+ HandCard {
+ card = c
+ action = props.action
+ ownBoard = props.ownBoard
+ prepareMove = props.prepareMove
+ key = index.toString()
+ }
+ }
+ }
+}
+
+private fun TurnAction.cardsToPlay(): List<HandCard>? = when (this) {
+ is TurnAction.PlayFromHand -> hand
+ is TurnAction.PlayFromDiscarded -> discardedCards
+ is TurnAction.PickNeighbourGuild -> neighbourGuildCards
+ is TurnAction.SayReady,
+ is TurnAction.Wait,
+ is TurnAction.WatchScore -> null
+}
+
+private external interface HandCardProps : Props {
+ var card: HandCard
+ var action: TurnAction
+ var ownBoard: Board
+ var prepareMove: (MoveType, HandCard, ResourceTransactionOptions) -> Unit
+}
+
+private val HandCard = FC<HandCardProps>("HandCard") { props ->
+ div {
+ css(ClassName("hand-card")) {
+ alignItems = AlignItems.flexEnd
+ display = Display.grid
+ margin = Margin(all = 0.2.rem)
+ }
+ CardImage {
+ css {
+ val isPlayable = props.card.playability.isPlayable || props.ownBoard.canPlayAnyCardForFree
+ handCardImgStyle(isPlayable)
+ }
+ this.card = props.card
+ }
+ actionButtons(props.card, props.action, props.ownBoard, props.prepareMove)
+ }
+}
+
+private fun ChildrenBuilder.actionButtons(
+ card: HandCard,
+ action: TurnAction,
+ ownBoard: Board,
+ prepareMove: (MoveType, HandCard, ResourceTransactionOptions) -> Unit,
+) {
+ div {
+ css {
+ justifyContent = JustifyContent.center
+ alignItems = AlignItems.flexEnd
+ display = None.none
+ gridRow = integer(1)
+ gridColumn = integer(1)
+
+ ancestorHover(".hand-card") {
+ display = Display.flex
+ }
+ }
+ BpButtonGroup {
+ when (action) {
+ is TurnAction.PlayFromHand -> {
+ playCardButton(card, HandAction.PLAY, prepareMove)
+ if (ownBoard.canPlayAnyCardForFree) {
+ playCardButton(card.copy(playability = CardPlayability.SPECIAL_FREE), HandAction.PLAY_FREE, prepareMove)
+ }
+ }
+ is TurnAction.PlayFromDiscarded -> playCardButton(card, HandAction.PLAY_FREE_DISCARDED, prepareMove)
+ is TurnAction.PickNeighbourGuild -> playCardButton(card, HandAction.COPY_GUILD, prepareMove)
+ is TurnAction.SayReady,
+ is TurnAction.Wait,
+ is TurnAction.WatchScore -> error("unsupported action in hand card: $action")
+ }
+
+ if (action.allowsBuildingWonder()) {
+ upgradeWonderButton(card, ownBoard.wonder.buildability, prepareMove)
+ }
+ if (action.allowsDiscarding()) {
+ discardButton(card, prepareMove)
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.playCardButton(
+ card: HandCard,
+ handAction: HandAction,
+ prepareMove: (MoveType, HandCard, ResourceTransactionOptions) -> Unit,
+) {
+ BpButton {
+ title = "${handAction.buttonTitle} (${cardPlayabilityInfo(card.playability)})"
+ large = true
+ intent = Intent.SUCCESS
+ disabled = !card.playability.isPlayable
+ onClick = { prepareMove(handAction.moveType, card, card.playability.transactionOptions) }
+
+ BpIcon { icon = handAction.icon }
+
+ if (card.playability.isPlayable && !card.playability.isFree) {
+ priceInfo(card.playability.minPrice)
+ }
+ }
+}
+
+private fun ChildrenBuilder.upgradeWonderButton(
+ card: HandCard,
+ wonderBuildability: WonderBuildability,
+ prepareMove: (MoveType, HandCard, ResourceTransactionOptions) -> Unit,
+) {
+ BpButton {
+ title = "UPGRADE WONDER (${wonderBuildabilityInfo(wonderBuildability)})"
+ large = true
+ intent = Intent.PRIMARY
+ disabled = !wonderBuildability.isBuildable
+ onClick = { prepareMove(MoveType.UPGRADE_WONDER, card, wonderBuildability.transactionsOptions) }
+
+ BpIcon { icon = IconNames.KEY_SHIFT }
+ if (wonderBuildability.isBuildable && !wonderBuildability.isFree) {
+ priceInfo(wonderBuildability.minPrice)
+ }
+ }
+}
+
+private fun ChildrenBuilder.discardButton(card: HandCard, prepareMove: (MoveType, HandCard, ResourceTransactionOptions) -> Unit) {
+ BpButton {
+ title = "DISCARD (+3 coins)" // TODO remove hardcoded value
+ large = true
+ intent = Intent.DANGER
+ icon = IconNames.CROSS
+ onClick = { prepareMove(MoveType.DISCARD, card, singleOptionNoTransactionNeeded()) }
+ }
+}
+
+private fun cardPlayabilityInfo(playability: CardPlayability) = when (playability.isPlayable) {
+ true -> priceText(-playability.minPrice)
+ false -> playability.playabilityLevel.message
+}
+
+private fun wonderBuildabilityInfo(buildability: WonderBuildability) = when (buildability.isBuildable) {
+ true -> priceText(-buildability.minPrice)
+ false -> buildability.playabilityLevel.message
+}
+
+private fun priceText(amount: Int) = when (amount.absoluteValue) {
+ 0 -> "free"
+ 1 -> "${pricePrefix(amount)}$amount coin"
+ else -> "${pricePrefix(amount)}$amount coins"
+}
+
+private fun pricePrefix(amount: Int) = when {
+ amount > 0 -> "+"
+ else -> ""
+}
+
+private fun ChildrenBuilder.priceInfo(amount: Int) {
+ goldIndicator(
+ amount = amount,
+ amountPosition = TokenCountPosition.OVER,
+ imgSize = 1.rem,
+ customCountStyle = {
+ fontFamily = FontFamily.sansSerif
+ fontSize = 0.8.rem
+ },
+ ) {
+ css {
+ position = Position.absolute
+ top = (-0.2).rem
+ left = (-0.2).rem
+ }
+ }
+}
+
+private fun PropertiesBuilder.handStyle() {
+ alignItems = AlignItems.center
+ bottom = 0.px
+ display = Display.flex
+ height = 345.px
+ left = 50.pct
+ maxHeight = 25.vw
+ position = Position.absolute
+ transform = translate(tx = (-50).pct, ty = 65.pct)
+ transition = Transition(TransitionProperty.all, duration = 0.5.s, timingFunction = TransitionTimingFunction.ease)
+ zIndex = integer(30)
+
+ hover {
+ bottom = 1.rem
+ zIndex = integer(60)
+ transform = translate(tx = (-50).pct, ty = 0.pct)
+ }
+}
+
+private fun PropertiesBuilder.handCardImgStyle(isPlayable: Boolean) {
+ gridRow = integer(1)
+ gridColumn = integer(1)
+ maxWidth = 13.vw
+ maxHeight = 60.vh
+ transition = Transition(TransitionProperty.all, duration = 0.1.s, timingFunction = TransitionTimingFunction.ease)
+ width = 11.rem
+
+ ancestorHover(".hand-card") {
+ boxShadow = BoxShadow(offsetX = 0.px, offsetY = 10.px, blurRadius = 40.px, color = NamedColor.black)
+ width = 14.rem
+ maxWidth = 15.vw
+ maxHeight = 90.vh
+ }
+
+ if (!isPlayable) {
+ filter = grayscale(50.pct) + contrast(50.pct)
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/HandRotationIndicator.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/HandRotationIndicator.kt
new file mode 100644
index 00000000..72cb6b65
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/HandRotationIndicator.kt
@@ -0,0 +1,56 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.cards.*
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+import web.cssom.*
+import web.cssom.Position
+
+fun ChildrenBuilder.handRotationIndicator(direction: HandRotationDirection) {
+ div {
+ css {
+ position = Position.absolute
+ display = Display.flex
+ alignItems = AlignItems.center
+ bottom = 25.vh
+ val sideDistance = 2.rem
+ when (direction) {
+ HandRotationDirection.LEFT -> left = sideDistance
+ HandRotationDirection.RIGHT -> right = sideDistance
+ }
+ }
+
+ title = "Your hand will be passed to the player on your $direction after playing this card."
+
+ when (direction) {
+ HandRotationDirection.LEFT -> {
+ BpIcon {
+ icon = IconNames.ARROW_LEFT
+ size = 25
+ }
+ handCardsImg()
+ }
+ HandRotationDirection.RIGHT -> {
+ handCardsImg()
+ BpIcon {
+ icon = IconNames.ARROW_RIGHT
+ size = 25
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.handCardsImg() {
+ img {
+ src = "images/hand-cards5.png"
+ css {
+ width = 4.rem
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PlayerPreparedCardPresenter.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PlayerPreparedCardPresenter.kt
new file mode 100644
index 00000000..627693e1
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PlayerPreparedCardPresenter.kt
@@ -0,0 +1,80 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import csstype.*
+import emotion.css.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.cards.*
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+import web.cssom.*
+
+external interface PlayerPreparedCardProps : Props {
+ var playerDisplayName: String
+ var username: String
+}
+
+val PlayerPreparedCard = FC<PlayerPreparedCardProps>("PlayerPreparedCard") { props ->
+ val cardBack = useSwSelector { it.gameState?.preparedCardsByUsername?.get(props.username) }
+
+ PlayerPreparedCardPresenter {
+ this.playerDisplayName = props.playerDisplayName
+ this.cardBack = cardBack
+ }
+}
+
+external interface PlayerPreparedCardPresenterProps : Props {
+ var playerDisplayName: String
+ var cardBack: CardBack?
+}
+
+private val PlayerPreparedCardPresenter = FC<PlayerPreparedCardPresenterProps>("PlayerPreparedCardPresenter") { props ->
+ val cardBack = props.cardBack
+ val sideSize = 30.px
+ div {
+ css {
+ width = sideSize
+ height = sideSize
+ }
+ title = if (cardBack == null) {
+ "${props.playerDisplayName} is still thinking…"
+ } else {
+ "${props.playerDisplayName} is ready to play this turn"
+ }
+
+ if (cardBack != null) {
+ CardBackImage {
+ this.cardBack = cardBack
+ css {
+ maxHeight = sideSize
+ }
+ }
+ } else {
+ RotatingGear {
+ css {
+ maxHeight = sideSize
+ }
+ }
+ }
+ }
+}
+
+private val RotatingGear = FC<PropsWithClassName> { props ->
+ img {
+ src = "images/gear-50.png"
+ css(props.className) {
+ animation = Animation(
+ name = keyframes {
+ to {
+ transform = rotate(360.deg)
+ }
+ },
+ duration = 1.5.s,
+ timingFunction = cubicBezier(0.2, 0.9, 0.7, 1.3),
+ )
+ animationIterationCount = AnimationIterationCount.infinite
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PreparedMove.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PreparedMove.kt
new file mode 100644
index 00000000..3ecdc741
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/PreparedMove.kt
@@ -0,0 +1,73 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.*
+import org.luxons.sevenwonders.model.cards.*
+import org.luxons.sevenwonders.ui.components.*
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+import web.cssom.*
+import web.cssom.Position
+import web.html.*
+
+fun ChildrenBuilder.preparedMove(
+ card: HandCard,
+ move: PlayerMove,
+ unprepareMove: () -> Unit,
+ block: HTMLAttributes<HTMLDivElement>.() -> Unit,
+) {
+ div {
+ block()
+ CardImage {
+ this.card = card
+ if (move.type == MoveType.DISCARD || move.type == MoveType.UPGRADE_WONDER) {
+ this.className = GameStyles.dimmedCard
+ }
+ }
+ if (move.type == MoveType.DISCARD) {
+ discardText()
+ }
+ if (move.type == MoveType.UPGRADE_WONDER) {
+ upgradeWonderSymbol()
+ }
+ div {
+ css {
+ position = web.cssom.Position.absolute
+ top = 0.px
+ right = 0.px
+ }
+ BpButton {
+ icon = IconNames.CROSS
+ title = "Cancel prepared move"
+ small = true
+ intent = Intent.DANGER
+ onClick = { unprepareMove() }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.discardText() {
+ div {
+ css(GlobalStyles.centerInPositionedParent, GameStyles.discardMoveText) {}
+ +"DISCARD"
+ }
+}
+
+private fun ChildrenBuilder.upgradeWonderSymbol() {
+ img {
+ src = "/images/wonder-upgrade-bright.png"
+ css {
+ width = 8.rem
+ position = Position.absolute
+ left = 10.pct
+ top = 50.pct
+ transform = translate((-50).pct, (-50).pct)
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/ScoreTable.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/ScoreTable.kt
new file mode 100644
index 00000000..cd54446f
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/ScoreTable.kt
@@ -0,0 +1,188 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.score.*
+import org.luxons.sevenwonders.ui.components.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.h1
+import react.dom.html.ReactHTML.sup
+import react.dom.html.ReactHTML.tbody
+import react.dom.html.ReactHTML.td
+import react.dom.html.ReactHTML.th
+import react.dom.html.ReactHTML.thead
+import react.dom.html.ReactHTML.tr
+import web.cssom.*
+
+fun ChildrenBuilder.scoreTableOverlay(scoreBoard: ScoreBoard, players: List<PlayerDTO>, leaveGame: () -> Unit) {
+ BpOverlay {
+ isOpen = true
+
+ BpCard {
+ css(GlobalStyles.fixedCenter, GameStyles.scoreBoard) {}
+
+ div {
+ // FIXME this doesn't look right, the scoreBoard class is applied at both levels
+ css(GameStyles.scoreBoard) { // loads the styles so that they can be picked up by bpCard
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.center
+ }
+ h1 {
+ css {
+ marginTop = 0.px
+ }
+ +"Score Board"
+ }
+ scoreTable(scoreBoard, players)
+ div {
+ css {
+ marginTop = 1.rem
+ }
+ BpButton {
+ intent = Intent.WARNING
+ rightIcon = "log-out"
+ large = true
+ onClick = { leaveGame() }
+
+ +"LEAVE"
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.scoreTable(scoreBoard: ScoreBoard, players: List<PlayerDTO>) {
+ BpHTMLTable {
+ bordered = false
+ interactive = true
+
+ thead {
+ tr {
+ th {
+ fullCenterInlineStyle()
+ +"Rank"
+ }
+ th {
+ fullCenterInlineStyle()
+ colSpan = 2
+
+ +"Player"
+ }
+ th {
+ fullCenterInlineStyle()
+ +"Score"
+ }
+ ScoreCategory.values().forEach {
+ th {
+ fullCenterInlineStyle()
+ +it.title
+ }
+ }
+ }
+ }
+ tbody {
+ scoreBoard.scores.forEachIndexed { index, score ->
+ val player = players[score.playerIndex]
+ tr {
+ td {
+ fullCenterInlineStyle()
+ ordinal(scoreBoard.ranks[index])
+ }
+ td {
+ fullCenterInlineStyle()
+ BpIcon {
+ icon = player.icon?.name ?: IconNames.USER
+ size = 25
+ }
+ }
+ td {
+ inlineStyles {
+ verticalAlign = VerticalAlign.middle
+ }
+ +player.displayName
+ }
+ td {
+ fullCenterInlineStyle()
+ BpTag {
+ large = true
+ round = true
+ minimal = true
+ className = GameStyles.totalScore
+
+ +"${score.totalPoints}"
+ }
+ }
+ ScoreCategory.values().forEach { cat ->
+ td {
+ fullCenterInlineStyle()
+ BpTag {
+ large = true
+ round = true
+ fill = true
+ icon = cat.icon
+ className = classNameForCategory(cat)
+
+ +"${score.pointsByCategory[cat]}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.ordinal(value: Int) {
+ +"$value"
+ sup { +value.ordinalIndicator() }
+}
+
+private fun Int.ordinalIndicator() = when {
+ this % 10 == 1 && this != 11 -> "st"
+ this % 10 == 2 && this != 12 -> "nd"
+ this % 10 == 3 && this != 13 -> "rd"
+ else -> "th"
+}
+
+private fun HTMLAttributes<*>.fullCenterInlineStyle() {
+ // inline styles necessary to overcome blueprintJS overrides
+ inlineStyles {
+ textAlign = TextAlign.center
+ verticalAlign = VerticalAlign.middle
+ }
+}
+
+private fun classNameForCategory(cat: ScoreCategory): ClassName = when (cat) {
+ ScoreCategory.CIVIL -> GameStyles.civilScore
+ ScoreCategory.SCIENCE -> GameStyles.scienceScore
+ ScoreCategory.MILITARY -> GameStyles.militaryScore
+ ScoreCategory.TRADE -> GameStyles.tradeScore
+ ScoreCategory.GUILD -> GameStyles.guildScore
+ ScoreCategory.WONDER -> GameStyles.wonderScore
+ ScoreCategory.GOLD -> GameStyles.goldScore
+}
+
+private val ScoreCategory.icon: String
+ get() = when (this) {
+ ScoreCategory.CIVIL -> IconNames.OFFICE
+ ScoreCategory.SCIENCE -> IconNames.LAB_TEST
+ ScoreCategory.MILITARY -> IconNames.CUT
+ ScoreCategory.TRADE -> IconNames.SWAP_HORIZONTAL
+ ScoreCategory.GUILD -> IconNames.CLEAN // stars
+ ScoreCategory.WONDER -> IconNames.SYMBOL_TRIANGLE_UP
+ ScoreCategory.GOLD -> IconNames.DOLLAR
+ }
+
+// Potentially useful emojis:
+// Greek temple: 🏛
+// Cog (science): ⚙️
+// Swords (war): ⚔️
+// Gold bag: 💰
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Tokens.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Tokens.kt
new file mode 100644
index 00000000..01975f7e
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/Tokens.kt
@@ -0,0 +1,155 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.resources.*
+import org.luxons.sevenwonders.ui.components.*
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+import react.dom.html.ReactHTML.span
+import web.cssom.*
+import web.html.*
+
+private fun getResourceTokenName(resourceType: ResourceType) = "resources/${resourceType.toString().lowercase()}"
+
+private fun getTokenImagePath(tokenName: String) = "/images/tokens/$tokenName.png"
+
+enum class TokenCountPosition {
+ LEFT,
+ RIGHT,
+ OVER,
+}
+
+fun ChildrenBuilder.goldIndicator(
+ amount: Int,
+ amountPosition: TokenCountPosition = TokenCountPosition.OVER,
+ imgSize: Length = 3.rem,
+ customCountStyle: PropertiesBuilder.() -> Unit = {},
+ block: HTMLAttributes<HTMLDivElement>.() -> Unit = {},
+) {
+ tokenWithCount(
+ tokenName = "coin",
+ title = "$amount gold coins",
+ imgSize = imgSize,
+ count = amount,
+ countPosition = amountPosition,
+ customCountStyle = customCountStyle,
+ block = block,
+ )
+}
+
+fun ChildrenBuilder.resourceImage(
+ resourceType: ResourceType,
+ title: String = resourceType.toString(),
+ size: Length?,
+) {
+ TokenImage {
+ this.tokenName = getResourceTokenName(resourceType)
+ this.title = title
+ this.size = size
+ }
+}
+
+fun ChildrenBuilder.tokenWithCount(
+ tokenName: String,
+ count: Int,
+ title: String = tokenName,
+ imgSize: Length? = null,
+ countPosition: TokenCountPosition = TokenCountPosition.RIGHT,
+ brightText: Boolean = false,
+ customCountStyle: PropertiesBuilder.() -> Unit = {},
+ block: HTMLAttributes<HTMLDivElement>.() -> Unit = {},
+) {
+ div {
+ block()
+ val tokenCountSize = if (imgSize != null) 0.6 * imgSize else 1.5.rem
+ when (countPosition) {
+ TokenCountPosition.RIGHT -> {
+ TokenImage {
+ this.tokenName = tokenName
+ this.title = title
+ this.size = imgSize
+ }
+ span {
+ css {
+ tokenCountStyle(tokenCountSize, brightText, customCountStyle)
+ marginLeft = 0.2.rem
+ }
+ +"× $count"
+ }
+ }
+
+ TokenCountPosition.LEFT -> {
+ span {
+ css {
+ tokenCountStyle(tokenCountSize, brightText, customCountStyle)
+ marginRight = 0.2.rem
+ }
+ +"$count ×"
+ }
+ TokenImage {
+ this.tokenName = tokenName
+ this.title = title
+ this.size = imgSize
+ }
+ }
+
+ TokenCountPosition.OVER -> {
+ div {
+ css {
+ position = Position.relative
+ // if container becomes large, this one stays small so that children stay on top of each other
+ width = Length.fitContent
+ }
+ TokenImage {
+ this.tokenName = tokenName
+ this.title = title
+ this.size = imgSize
+ }
+ span {
+ css(GlobalStyles.centerInPositionedParent) {
+ tokenCountStyle(tokenCountSize, brightText, customCountStyle)
+ }
+ +"$count"
+ }
+ }
+ }
+ }
+ }
+}
+
+external interface TokenImageProps : Props {
+ var tokenName: String
+ var title: String?
+ var size: Length?
+}
+
+val TokenImage = FC<TokenImageProps> { props ->
+ img {
+ src = getTokenImagePath(props.tokenName)
+ title = props.title ?: props.tokenName
+ alt = props.tokenName
+
+ css {
+ height = props.size ?: 100.pct
+ if (props.size != null) {
+ width = props.size
+ }
+ verticalAlign = VerticalAlign.middle
+ }
+ }
+}
+
+private fun PropertiesBuilder.tokenCountStyle(
+ size: Length,
+ brightText: Boolean,
+ customStyle: PropertiesBuilder.() -> Unit = {},
+) {
+ fontFamily = string("Acme")
+ fontSize = size
+ verticalAlign = VerticalAlign.middle
+ color = if (brightText) NamedColor.white else NamedColor.black
+ customStyle()
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/TransactionsSelector.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/TransactionsSelector.kt
new file mode 100644
index 00000000..cdf97ad9
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/game/TransactionsSelector.kt
@@ -0,0 +1,265 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.resources.*
+import org.luxons.sevenwonders.model.resources.Provider
+import org.luxons.sevenwonders.ui.components.gameBrowser.*
+import org.luxons.sevenwonders.ui.utils.*
+import org.luxons.sevenwonders.ui.utils.Margin
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.p
+import react.dom.html.ReactHTML.tbody
+import react.dom.html.ReactHTML.td
+import react.dom.html.ReactHTML.tr
+import web.cssom.*
+import web.html.*
+
+fun ChildrenBuilder.transactionsSelectorDialog(
+ state: TransactionSelectorState?,
+ neighbours: Pair<PlayerDTO, PlayerDTO>,
+ prepareMove: (PlayerMove) -> Unit,
+ cancelTransactionSelection: () -> Unit,
+) {
+ BpDialog {
+ isOpen = state != null
+ titleText = "Trading time!"
+ canEscapeKeyClose = true
+ canOutsideClickClose = true
+ isCloseButtonShown = true
+ onClose = { cancelTransactionSelection() }
+
+ className = GameStyles.transactionsSelector
+
+ BpDialogBody {
+ p {
+ +"You don't have enough resources to perform this move, but you can buy them from neighbours. "
+ +"Please pick an option:"
+ }
+ if (state != null) { // should always be true when the dialog is rendered
+ div {
+ css {
+ margin = Margin(all = Auto.auto)
+ display = Display.flex
+ alignItems = AlignItems.center
+ }
+ neighbour(neighbours.first)
+ div {
+ css {
+ flexGrow = number(1.0)
+ margin = Margin(vertical = 0.rem, horizontal = 0.5.rem)
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.center
+ }
+ OptionsTable {
+ this.state = state
+ this.prepareMove = prepareMove
+ }
+ }
+ neighbour(neighbours.second)
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.neighbour(player: PlayerDTO) {
+ div {
+ css {
+ width = 12.rem
+
+ // center the icon
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.center
+ }
+ PlayerInfo {
+ this.player = player
+ this.iconSize = 40
+ this.orientation = FlexDirection.column
+ this.ellipsize = false
+ }
+ }
+}
+
+private external interface OptionsTableProps : PropsWithChildren {
+ var state: TransactionSelectorState
+ var prepareMove: (PlayerMove) -> Unit
+}
+
+private val OptionsTable = FC<OptionsTableProps> { props ->
+ val state = props.state
+ val prepareMove = props.prepareMove
+
+ var expanded by useState { false }
+
+ val bestPrice = state.transactionsOptions.bestPrice
+ val (cheapestOptions, otherOptions) = state.transactionsOptions.partition { it.totalPrice == bestPrice }
+
+ BpHTMLTable {
+ interactive = true
+ tbody {
+ cheapestOptions.forEach { transactions ->
+ transactionsOptionRow(
+ transactions = transactions,
+ showBestPriceIndicator = expanded,
+ onClick = { prepareMove(PlayerMove(state.moveType, state.card.name, transactions)) },
+ )
+ }
+ if (expanded) {
+ otherOptions.forEach { transactions ->
+ transactionsOptionRow(
+ transactions = transactions,
+ showBestPriceIndicator = false,
+ onClick = { prepareMove(PlayerMove(state.moveType, state.card.name, transactions)) },
+ )
+ }
+ }
+ }
+ }
+ if (otherOptions.isNotEmpty()) {
+ val icon = if (expanded) "chevron-up" else "chevron-down"
+ val text = if (expanded) "Hide expensive options" else "Show more expensive options"
+ BpButton {
+ this.minimal = true
+ this.small = true
+ this.icon = icon
+ this.rightIcon = icon
+ this.onClick = { expanded = !expanded }
+
+ +text
+ }
+ }
+}
+
+private fun ChildrenBuilder.transactionsOptionRow(
+ transactions: PricedResourceTransactions,
+ showBestPriceIndicator: Boolean,
+ onClick: () -> Unit,
+) {
+ tr {
+ css {
+ cursor = Cursor.pointer
+ alignItems = AlignItems.center
+ }
+ this.onClick = { onClick() }
+ // there should be at most one of each
+ val leftTr = transactions.firstOrNull { it.provider == Provider.LEFT_PLAYER }
+ val rightTr = transactions.firstOrNull { it.provider == Provider.RIGHT_PLAYER }
+ td {
+ transactionCellCss()
+ div {
+ css { opacity = number(if (leftTr == null) 0.5 else 1.0) }
+ transactionCellInnerCss()
+ BpIcon {
+ icon = IconNames.CARET_LEFT
+ size = IconSize.LARGE
+ }
+ goldIndicator(leftTr?.totalPrice ?: 0, imgSize = 2.5.rem)
+ }
+ }
+ td {
+ transactionCellCss()
+ if (leftTr != null) {
+ resourceList(leftTr.resources)
+ }
+ }
+ td {
+ transactionCellCss()
+ css { width = 1.5.rem }
+ if (showBestPriceIndicator) {
+ bestPriceIndicator()
+ }
+ }
+ td {
+ transactionCellCss()
+ if (rightTr != null) {
+ resourceList(rightTr.resources)
+ }
+ }
+ td {
+ transactionCellCss()
+ div {
+ css { opacity = number(if (rightTr == null) 0.5 else 1.0) }
+ transactionCellInnerCss()
+ goldIndicator(rightTr?.totalPrice ?: 0, imgSize = 2.5.rem)
+ BpIcon {
+ icon = IconNames.CARET_RIGHT
+ size = IconSize.LARGE
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.bestPriceIndicator() {
+ div {
+ css(GameStyles.bestPrice){}
+ +"Best\nprice!"
+ }
+}
+
+private fun HTMLAttributes<HTMLTableCellElement>.transactionCellCss() {
+ // we need inline styles to win over BlueprintJS's styles (which are more specific than .class)
+ inlineStyles {
+ verticalAlign = VerticalAlign.middle
+ textAlign = TextAlign.center
+ }
+}
+
+private fun HTMLAttributes<HTMLDivElement>.transactionCellInnerCss() {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ alignItems = AlignItems.center
+ }
+}
+
+private fun ChildrenBuilder.resourceList(countedResources: List<CountedResource>) {
+ val resources = countedResources.toRepeatedTypesList()
+
+ // The biggest card is the Palace and requires 7 resources (1 of each).
+ // We always have at least 1 resource on our wonder, so we'll never need to buy more than 6.
+ // Therefore, 3 by row seems decent. When there are 4 items, it's visually better to have a 2x2 matrix, though.
+ val rows = resources.chunked(if (resources.size == 4) 2 else 3)
+
+ val imgSize = 1.5
+ div {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.center
+ justifyContent = JustifyContent.center
+ flexGrow = number(1.0)
+ // this ensures stable dimensions, no matter how many resources (up to 2x3 matrix)
+ width = (imgSize * 3).rem
+ height = (imgSize * 2).rem
+ }
+ rows.forEach { row ->
+ div {
+ resourceRowCss()
+ row.forEach {
+ resourceImage(it, size = imgSize.rem)
+ }
+ }
+ }
+ }
+}
+
+private fun HTMLAttributes<HTMLDivElement>.resourceRowCss() {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ alignItems = AlignItems.center
+ margin = Margin(vertical = 0.px, horizontal = Auto.auto)
+ }
+}
+
+private fun List<CountedResource>.toRepeatedTypesList(): List<ResourceType> = flatMap { cr -> List(cr.count) { cr.type } }
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt
new file mode 100644
index 00000000..e0f7dd21
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt
@@ -0,0 +1,58 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import emotion.react.*
+import org.luxons.sevenwonders.ui.names.*
+import org.luxons.sevenwonders.ui.redux.*
+import react.*
+import react.dom.html.ReactHTML.form
+import web.cssom.*
+
+val CreateGameForm = FC {
+ var gameName by useState("")
+
+ val dispatch = useSwDispatch()
+ val createGame = { dispatch(RequestCreateGame(gameName)) }
+
+ form {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ }
+ onSubmit = { e ->
+ e.preventDefault()
+ createGame()
+ }
+
+ BpInputGroup {
+ large = true
+ placeholder = "Game name"
+ value = gameName
+ onChange = { e ->
+ val input = e.currentTarget
+ gameName = input.value
+ }
+ rightElement = BpButton.create {
+ title = "Generate random name"
+ icon = IconNames.RANDOM
+ minimal = true
+ onClick = { gameName = randomGameName() }
+ }
+ }
+ BpButton {
+ title = "Create the game"
+ intent = Intent.PRIMARY
+ icon = IconNames.ARROW_RIGHT
+ large = true
+ onClick = { e ->
+ e.preventDefault() // prevents refreshing the page when pressing Enter
+ createGame()
+ }
+
+ css {
+ marginLeft = 0.2.rem
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt
new file mode 100644
index 00000000..10fb9d81
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt
@@ -0,0 +1,69 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import blueprintjs.core.*
+import emotion.react.*
+import org.luxons.sevenwonders.ui.components.*
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.h1
+import react.dom.html.ReactHTML.h2
+import web.cssom.*
+
+val GameBrowser = FC {
+ div {
+ css(GlobalStyles.fullscreen, GlobalStyles.zeusBackground) {
+ padding = Padding(all = 1.rem)
+ }
+ div {
+ css(ClassName(Classes.DARK)) {
+ margin = Margin(vertical = 0.px, horizontal = Auto.auto)
+ maxWidth = GlobalStyles.preGameWidth
+ }
+ div {
+ css {
+ display = Display.flex
+ justifyContent = JustifyContent.spaceBetween
+ }
+ h1 { +"Games" }
+ CurrentPlayerInfo()
+ }
+
+ BpCard {
+ css {
+ marginBottom = 1.rem
+ }
+
+ h2 {
+ css {
+ marginTop = 0.px
+ }
+ +"Create a Game"
+ }
+ CreateGameForm()
+ }
+
+ BpCard {
+ h2 {
+ css {
+ marginTop = 0.px
+ }
+ +"Join a Game"
+ }
+ GameList()
+ }
+ }
+ }
+}
+
+val CurrentPlayerInfo = FC {
+ val connectedPlayer = useSwSelector { it.connectedPlayer }
+ PlayerInfo {
+ player = connectedPlayer
+ iconSize = 30
+ showUsername = true
+ orientation = FlexDirection.row
+ ellipsize = false
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt
new file mode 100644
index 00000000..2919b065
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt
@@ -0,0 +1,213 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.api.State
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.col
+import react.dom.html.ReactHTML.colgroup
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.span
+import react.dom.html.ReactHTML.tbody
+import react.dom.html.ReactHTML.td
+import react.dom.html.ReactHTML.th
+import react.dom.html.ReactHTML.thead
+import react.dom.html.ReactHTML.tr
+import web.cssom.*
+import react.State as RState
+
+external interface GameListStateProps : Props {
+ var connectedPlayer: ConnectedPlayer
+ var games: List<LobbyDTO>
+}
+
+external interface GameListDispatchProps : Props {
+ var joinGame: (Long) -> Unit
+}
+
+external interface GameListProps : GameListStateProps, GameListDispatchProps
+
+val GameList = connectStateAndDispatch<GameListStateProps, GameListDispatchProps, GameListProps>(
+ clazz = GameListPresenter::class,
+ mapStateToProps = { state, _ ->
+ connectedPlayer = state.connectedPlayer ?: error("there should be a connected player")
+ games = state.games
+ },
+ mapDispatchToProps = { dispatch, _ ->
+ joinGame = { gameId -> dispatch(RequestJoinGame(gameId = gameId)) }
+ },
+)
+
+private class GameListPresenter(props: GameListProps) : Component<GameListProps, RState>(props) {
+
+ override fun render() = Fragment.create {
+ if (props.games.isEmpty()) {
+ noGamesInfo()
+ } else {
+ gamesTable()
+ }
+ }
+
+ private fun ChildrenBuilder.noGamesInfo() {
+ BpNonIdealState {
+ icon = IconNames.GEOSEARCH
+ titleText = "No games to join"
+
+ div {
+ css(ClassName(Classes.RUNNING_TEXT)) {
+ maxWidth = 35.rem
+ }
+ +"Nobody seems to be playing at the moment. "
+ +"Don't be disappointed, you can always create your own game, and play with bots if you're alone."
+ }
+ }
+ }
+
+ private fun ChildrenBuilder.gamesTable() {
+ BpHTMLTable {
+ css {
+ width = 100.pct
+ }
+
+ columnWidthsSpec()
+ thead {
+ gameListHeaderRow()
+ }
+ tbody {
+ props.games.forEach {
+ gameListItemRow(it)
+ }
+ }
+ }
+ }
+
+ private fun ChildrenBuilder.columnWidthsSpec() {
+ colgroup {
+ col {
+ css {
+ width = 40.rem
+ }
+ }
+ col {
+ css {
+ width = 5.rem
+ textAlign = TextAlign.center
+ }
+ }
+ col {
+ css {
+ width = 5.rem
+ textAlign = TextAlign.center // use inline style on th instead to overcome blueprint style
+ }
+ }
+ col {
+ css {
+ width = 3.rem
+ textAlign = TextAlign.center
+ }
+ }
+ }
+ }
+
+ private fun ChildrenBuilder.gameListHeaderRow() = tr {
+ th {
+ +"Name"
+ }
+ th {
+ inlineStyles { gameTableHeaderCellStyle() }
+ +"Status"
+ }
+ th {
+ inlineStyles { gameTableHeaderCellStyle() }
+ +"Players"
+ }
+ th {
+ inlineStyles { gameTableHeaderCellStyle() }
+ +"Join"
+ }
+ }
+
+ private fun ChildrenBuilder.gameListItemRow(lobby: LobbyDTO) = tr {
+ key = lobby.id.toString()
+ // inline styles necessary to overcome BlueprintJS's verticalAlign=top
+ td {
+ inlineStyles { gameTableCellStyle() }
+ +lobby.name
+ }
+ td {
+ inlineStyles {
+ textAlign = TextAlign.center
+ gameTableCellStyle()
+ }
+ gameStatus(lobby.state)
+ }
+ td {
+ inlineStyles { gameTableCellStyle() }
+ playerCount(lobby.players.size)
+ }
+ td {
+ inlineStyles { gameTableCellStyle() }
+ joinButton(lobby)
+ }
+ }
+
+ private fun PropertiesBuilder.gameTableHeaderCellStyle() {
+ textAlign = TextAlign.center
+ }
+
+ private fun PropertiesBuilder.gameTableCellStyle() {
+ verticalAlign = VerticalAlign.middle
+ }
+
+ private fun ChildrenBuilder.gameStatus(state: State) {
+ val intent = when (state) {
+ State.LOBBY -> Intent.SUCCESS
+ State.PLAYING -> Intent.WARNING
+ State.FINISHED -> Intent.DANGER
+ }
+ BpTag {
+ this.minimal = true
+ this.intent = intent
+
+ +state.toString()
+ }
+ }
+
+ private fun ChildrenBuilder.playerCount(nPlayers: Int) {
+ div {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ justifyContent = JustifyContent.center
+ }
+ title = "Number of players"
+ BpIcon {
+ icon = IconNames.PEOPLE
+ title = null
+ }
+ span {
+ css {
+ marginLeft = 0.3.rem
+ }
+ +nPlayers.toString()
+ }
+ }
+ }
+
+ private fun ChildrenBuilder.joinButton(lobby: LobbyDTO) {
+ val joinability = lobby.joinability(props.connectedPlayer.displayName)
+ BpButton {
+ minimal = true
+ large = true
+ title = joinability.tooltip
+ icon = "arrow-right"
+ disabled = !joinability.canDo
+ onClick = { props.joinGame(lobby.id) }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt
new file mode 100644
index 00000000..d7a9a80f
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt
@@ -0,0 +1,105 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import blueprintjs.core.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.api.*
+import react.*
+import react.State
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.span
+import web.cssom.*
+
+external interface PlayerInfoProps : PropsWithChildren {
+ var player: BasicPlayerInfo?
+ var showUsername: Boolean?
+ var iconSize: Int?
+ var orientation: FlexDirection?
+ var ellipsize: Boolean?
+}
+
+val PlayerInfo = PlayerInfoPresenter::class.react
+
+private class PlayerInfoPresenter(props: PlayerInfoProps) : Component<PlayerInfoProps, State>(props) {
+
+ override fun render() = div.create {
+ val orientation = props.orientation ?: FlexDirection.row
+ css {
+ display = Display.flex
+ alignItems = AlignItems.center
+ flexDirection = orientation
+ }
+ props.player?.let {
+ BpIcon {
+ icon = it.icon?.name ?: "user"
+ size = props.iconSize ?: 30
+ }
+ if (props.showUsername == true) {
+ playerNameWithUsername(it.displayName, it.username) {
+ iconSeparationMargin(orientation)
+ }
+ } else {
+ playerName(it.displayName) {
+ iconSeparationMargin(orientation)
+ }
+ }
+ }
+ }
+
+ private fun ChildrenBuilder.playerName(displayName: String, style: PropertiesBuilder.() -> Unit = {}) {
+ span {
+ css {
+ fontSize = 1.rem
+ if (props.orientation == FlexDirection.column) {
+ textAlign = TextAlign.center
+ }
+ style()
+ }
+ // TODO replace by BlueprintJS's Text elements (built-in ellipsize based on width)
+ val maxDisplayNameLength = 15
+ val ellipsize = props.ellipsize ?: true
+ if (ellipsize && displayName.length > maxDisplayNameLength) {
+ title = displayName
+ +displayName.ellipsize(maxDisplayNameLength)
+ } else {
+ +displayName
+ }
+ }
+ }
+
+ private fun String.ellipsize(maxLength: Int) = take(maxLength - 1) + "…"
+
+ private fun PropertiesBuilder.iconSeparationMargin(orientation: FlexDirection) {
+ val margin = 0.4.rem
+ when (orientation) {
+ FlexDirection.row -> marginLeft = margin
+ FlexDirection.column -> marginTop = margin
+ FlexDirection.rowReverse -> marginRight = margin
+ FlexDirection.columnReverse -> marginBottom = margin
+ else -> error("Unsupported orientation '$orientation' for player info component")
+ }
+ }
+
+ private fun ChildrenBuilder.playerNameWithUsername(
+ displayName: String,
+ username: String,
+ style: PropertiesBuilder.() -> Unit = {}
+ ) {
+ div {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ style()
+ }
+ playerName(displayName)
+ span {
+ css {
+ marginTop = 0.1.rem
+ color = NamedColor.lightgray
+ fontSize = 0.8.rem
+ }
+ +"($username)"
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
new file mode 100644
index 00000000..ba37c09d
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
@@ -0,0 +1,65 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import emotion.react.*
+import org.luxons.sevenwonders.ui.names.*
+import org.luxons.sevenwonders.ui.redux.*
+import react.*
+import react.dom.html.ReactHTML.form
+import web.cssom.*
+
+val ChooseNameForm = FC {
+ val dispatch = useSwDispatch()
+ ChooseNameFormPresenter {
+ chooseUsername = { name -> dispatch(RequestChooseName(name)) }
+ }
+}
+
+private external interface ChooseNameFormPresenterProps : PropsWithChildren {
+ var chooseUsername: (String) -> Unit
+}
+
+private val ChooseNameFormPresenter = FC<ChooseNameFormPresenterProps> { props ->
+ var usernameState by useState("")
+
+ form {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ }
+ onSubmit = { e ->
+ e.preventDefault()
+ props.chooseUsername(usernameState)
+ }
+ BpInputGroup {
+ large = true
+ placeholder = "Username"
+ value = usernameState
+ onChange = { e ->
+ val input = e.currentTarget
+ usernameState = input.value
+ }
+ rightElement = BpButton.create {
+ title = "Generate random name"
+ icon = IconNames.RANDOM
+ minimal = true
+ onClick = { usernameState = randomGreekName() }
+ }
+ }
+ BpButton {
+ title = "Start"
+ icon = IconNames.ARROW_RIGHT
+ intent = Intent.PRIMARY
+ large = true
+ onClick = { e ->
+ e.preventDefault()
+ props.chooseUsername(usernameState)
+ }
+
+ css {
+ marginLeft = 0.2.rem
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt
new file mode 100644
index 00000000..81f4c736
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt
@@ -0,0 +1,22 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import emotion.react.*
+import org.luxons.sevenwonders.ui.components.*
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+
+private const val LOGO = "images/logo-7-wonders.png"
+
+val Home = FC("Home") {
+ div {
+ css(GlobalStyles.fullscreen, GlobalStyles.zeusBackground, HomeStyles.centerChildren) {}
+
+ img {
+ src = LOGO
+ alt = "Seven Wonders"
+ }
+
+ ChooseNameForm()
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt
new file mode 100644
index 00000000..015e78d6
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt
@@ -0,0 +1,15 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import csstype.*
+import emotion.css.*
+import web.cssom.*
+
+object HomeStyles {
+
+ val centerChildren = ClassName {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.center
+ justifyContent = JustifyContent.center
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt
new file mode 100644
index 00000000..0330a192
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt
@@ -0,0 +1,272 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.wonders.*
+import org.luxons.sevenwonders.ui.components.*
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.h1
+import react.dom.html.ReactHTML.h2
+import react.dom.html.ReactHTML.h3
+import react.dom.html.ReactHTML.h4
+import web.cssom.*
+import web.cssom.Position
+
+private val BOT_NAMES = listOf("Wall-E", "B-Max", "Sonny", "T-800", "HAL", "GLaDOS", "R2-D2", "Bender", "AWESOM-O")
+
+val Lobby = FC(displayName = "Lobby") {
+ val lobby = useSwSelector { it.currentLobby }
+ val player = useSwSelector { it.currentPlayer }
+
+ val dispatch = useSwDispatch()
+
+ if (lobby == null || player == null) {
+ BpNonIdealState {
+ icon = IconNames.ERROR
+ titleText = "Error: no current game"
+ }
+ } else {
+ LobbyPresenter {
+ currentGame = lobby
+ currentPlayer = player
+
+ startGame = { dispatch(RequestStartGame()) }
+ addBot = { name -> dispatch(RequestAddBot(name)) }
+ leaveLobby = { dispatch(RequestLeaveLobby()) }
+ disbandLobby = { dispatch(RequestDisbandLobby()) }
+ reorderPlayers = { orderedPlayers -> dispatch(RequestReorderPlayers(orderedPlayers)) }
+ reassignWonders = { wonders -> dispatch(RequestReassignWonders(wonders)) }
+ }
+ }
+}
+
+private external interface LobbyPresenterProps : Props {
+ var currentGame: LobbyDTO
+ var currentPlayer: PlayerDTO
+ var startGame: () -> Unit
+ var addBot: (displayName: String) -> Unit
+ var leaveLobby: () -> Unit
+ var disbandLobby: () -> Unit
+ var reorderPlayers: (orderedPlayers: List<String>) -> Unit
+ var reassignWonders: (wonders: List<AssignedWonder>) -> Unit
+}
+
+private val LobbyPresenter = FC<LobbyPresenterProps> { props ->
+ div {
+ css(GlobalStyles.fullscreen, GlobalStyles.zeusBackground) {
+ padding = Padding(all = 1.rem)
+ }
+ div {
+ css(ClassName(Classes.DARK), LobbyStyles.contentContainer) {
+ margin = Margin(vertical = 0.rem, horizontal = Auto.auto)
+ maxWidth = GlobalStyles.preGameWidth
+ }
+ h1 { +"${props.currentGame.name} — Lobby" }
+
+ radialPlayerList(props.currentGame.players, props.currentPlayer) {
+ css {
+ // to make players more readable on the background
+ background = "radial-gradient(closest-side, black 20%, transparent)".unsafeCast<Gradient>()
+ // make it bigger so the background covers more ground
+ width = 40.rem
+ height = 40.rem
+ }
+ }
+ actionButtons(props.currentPlayer, props.currentGame, props.startGame, props.leaveLobby, props.disbandLobby, props.addBot)
+
+ if (props.currentPlayer.isGameOwner) {
+ setupPanel(props.currentGame, props.reorderPlayers, props.reassignWonders)
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.actionButtons(
+ currentPlayer: PlayerDTO,
+ currentGame: LobbyDTO,
+ startGame: () -> Unit,
+ leaveLobby: () -> Unit,
+ disbandLobby: () -> Unit,
+ addBot: (String) -> Unit,
+) {
+ div {
+ css {
+ position = Position.absolute
+ bottom = 2.rem
+ left = 50.pct
+ transform = translate((-50).pct)
+
+ width = 70.pct
+ display = Display.flex
+ justifyContent = JustifyContent.spaceAround
+ }
+ if (currentPlayer.isGameOwner) {
+ BpButtonGroup {
+ leaveButton(leaveLobby)
+ disbandButton(disbandLobby)
+ }
+ BpButtonGroup {
+ addBotButton(currentGame, addBot)
+ startButton(currentGame.startability(currentPlayer.username), startGame)
+ }
+ } else {
+ leaveButton(leaveLobby)
+ }
+ }
+}
+
+private fun ChildrenBuilder.startButton(startability: Actionability, startGame: () -> Unit) {
+ BpButton {
+ large = true
+ intent = Intent.PRIMARY
+ icon = IconNames.PLAY
+ title = startability.tooltip
+ disabled = !startability.canDo
+ onClick = { startGame() }
+
+ +"START"
+ }
+}
+
+private fun ChildrenBuilder.setupPanel(
+ currentGame: LobbyDTO,
+ reorderPlayers: (usernames: List<String>) -> Unit,
+ reassignWonders: (wonders: List<AssignedWonder>) -> Unit,
+) {
+ div {
+ className = LobbyStyles.setupPanel
+
+ BpCard {
+ elevation = Elevation.TWO
+ className = ClassName(Classes.DARK)
+
+ h2 {
+ css {
+ marginTop = 0.px
+ }
+ +"Game setup"
+ }
+ BpDivider()
+ h3 {
+ +"Players"
+ }
+ reorderPlayersButton(currentGame, reorderPlayers)
+ h3 {
+ +"Wonders"
+ }
+ WonderSettingsGroup {
+ this.currentGame = currentGame
+ this.reassignWonders = reassignWonders
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.addBotButton(currentGame: LobbyDTO, addBot: (String) -> Unit) {
+ BpButton {
+ large = true
+ icon = IconNames.PLUS
+ rightIcon = IconNames.DESKTOP
+ intent = Intent.PRIMARY
+ title = if (currentGame.maxPlayersReached) "Max players reached" else "Add a bot to this game"
+ disabled = currentGame.maxPlayersReached
+ onClick = { addBot(randomBotNameUnusedIn(currentGame)) }
+ }
+}
+
+private fun randomBotNameUnusedIn(currentGame: LobbyDTO): String {
+ val availableBotNames = BOT_NAMES.filter { name ->
+ currentGame.players.none { it.displayName == name }
+ }
+ return availableBotNames.random()
+}
+
+private fun ChildrenBuilder.reorderPlayersButton(currentGame: LobbyDTO, reorderPlayers: (usernames: List<String>) -> Unit) {
+ BpButton {
+ icon = IconNames.RANDOM
+ rightIcon = IconNames.PEOPLE
+ title = "Re-order players randomly"
+ onClick = { reorderPlayers(currentGame.players.map { it.username }.shuffled()) }
+
+ +"Reorder players"
+ }
+}
+
+private external interface WonderSettingsGroupProps : Props {
+ var currentGame: LobbyDTO
+ var reassignWonders: (List<AssignedWonder>) -> Unit
+}
+
+private val WonderSettingsGroup = FC<WonderSettingsGroupProps> { props ->
+ val reassignWonders = props.reassignWonders
+
+ BpButton {
+ icon = IconNames.RANDOM
+ title = "Re-assign wonders to players randomly"
+ onClick = { reassignWonders(randomWonderAssignments(props.currentGame)) }
+
+ +"Randomize wonders"
+ }
+ h4 {
+ +"Select wonder sides:"
+ }
+ BpButtonGroup {
+ BpButton {
+ icon = IconNames.RANDOM
+ title = "Re-roll wonder sides randomly"
+ onClick = { reassignWonders(assignedWondersWithRandomSides(props.currentGame)) }
+ }
+ BpButton {
+ title = "Choose side A for everyone"
+ onClick = { reassignWonders(assignedWondersWithForcedSide(props.currentGame, WonderSide.A)) }
+
+ +"A"
+ }
+ BpButton {
+ title = "Choose side B for everyone"
+ onClick = { reassignWonders(assignedWondersWithForcedSide(props.currentGame, WonderSide.B)) }
+
+ +"B"
+ }
+ }
+}
+
+private fun randomWonderAssignments(currentGame: LobbyDTO): List<AssignedWonder> =
+ currentGame.allWonders.deal(currentGame.players.size)
+
+private fun assignedWondersWithForcedSide(
+ currentGame: LobbyDTO,
+ side: WonderSide
+) = currentGame.players.map { currentGame.findWonder(it.wonder.name).withSide(side) }
+
+private fun assignedWondersWithRandomSides(currentGame: LobbyDTO) =
+ currentGame.players.map { currentGame.findWonder(it.wonder.name) }.map { it.withRandomSide() }
+
+private fun ChildrenBuilder.leaveButton(leaveLobby: () -> Unit) {
+ BpButton {
+ large = true
+ intent = Intent.WARNING
+ icon = "arrow-left"
+ title = "Leave the lobby and go back to the game browser"
+ onClick = { leaveLobby() }
+
+ +"LEAVE"
+ }
+}
+
+private fun ChildrenBuilder.disbandButton(disbandLobby: () -> Unit) {
+ BpButton {
+ large = true
+ intent = Intent.DANGER
+ icon = IconNames.DELETE
+ title = "Disband the group and go back to the game browser"
+ onClick = { disbandLobby() }
+
+ +"DISBAND"
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/LobbyStyles.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/LobbyStyles.kt
new file mode 100644
index 00000000..6b5dbe48
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/LobbyStyles.kt
@@ -0,0 +1,20 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import emotion.css.*
+import org.luxons.sevenwonders.ui.components.*
+import web.cssom.*
+
+object LobbyStyles {
+
+ val contentContainer = ClassName {
+ margin = Margin(vertical = 0.px, horizontal = Auto.auto)
+ maxWidth = GlobalStyles.preGameWidth
+ }
+
+ val setupPanel = ClassName {
+ position = Position.fixed
+ top = 2.rem
+ right = 1.rem
+ width = 20.rem
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt
new file mode 100644
index 00000000..1f88bebe
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt
@@ -0,0 +1,117 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.ui.components.*
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.li
+import react.dom.html.ReactHTML.ul
+import web.cssom.*
+import web.html.*
+
+fun <T> ChildrenBuilder.radialList(
+ items: List<T>,
+ centerElement: ReactElement<*>,
+ renderItem: (T) -> ReactElement<*>,
+ getKey: (T) -> String,
+ itemWidth: Int,
+ itemHeight: Int,
+ options: RadialConfig = RadialConfig(),
+ block: HTMLAttributes<HTMLDivElement>.() -> Unit = {},
+) {
+ val containerWidth = options.diameter + itemWidth
+ val containerHeight = options.diameter + itemHeight
+
+ div {
+ css(GlobalStyles.fixedCenter) {
+ zeroMargins()
+ width = containerWidth.px
+ height = containerHeight.px
+ }
+ block()
+ radialListItems(items, renderItem, getKey, options)
+ radialListCenter(centerElement)
+ }
+}
+
+private fun <T> ChildrenBuilder.radialListItems(
+ items: List<T>,
+ renderItem: (T) -> ReactElement<*>,
+ getKey: (T) -> String,
+ radialConfig: RadialConfig,
+) {
+ val offsets = offsetsFromCenter(items.size, radialConfig)
+ ul {
+ css {
+ zeroMargins()
+ transition = Transition(
+ property = TransitionProperty.all,
+ duration = 500.ms,
+ timingFunction = TransitionTimingFunction.easeInOut,
+ )
+ zIndex = integer(1)
+ width = radialConfig.diameter.px
+ height = radialConfig.diameter.px
+ absoluteCenter()
+ }
+ // We ensure a stable order of the DOM elements so that position animations look nice.
+ // We still respect the order of the items in the list when placing them along the circle.
+ val indexByKey = buildMap {
+ items.forEachIndexed { index, item -> put(getKey(item), index) }
+ }
+ items.sortedBy { getKey(it) }.forEach { item ->
+ val key = getKey(item)
+ radialListItem(renderItem(item), key, offsets[indexByKey.getValue(key)])
+ }
+ }
+}
+
+private fun ChildrenBuilder.radialListItem(item: ReactElement<*>, key: String, offset: CartesianCoords) {
+ li {
+ css {
+ display = Display.block
+ position = Position.absolute
+ top = 50.pct
+ left = 50.pct
+ zeroMargins()
+ listStyleType = Globals.unset
+ transition = Transition(
+ property = TransitionProperty.all,
+ duration = 500.ms,
+ timingFunction = TransitionTimingFunction.easeInOut,
+ )
+ zIndex = integer(1)
+ transform = translate(offset.x.px - 50.pct, offset.y.px - 50.pct)
+ }
+ this.key = key
+
+ child(item)
+ }
+}
+
+private fun ChildrenBuilder.radialListCenter(centerElement: ReactElement<*>?) {
+ if (centerElement == null) {
+ return
+ }
+ div {
+ css {
+ zIndex = integer(0)
+ absoluteCenter()
+ }
+ child(centerElement)
+ }
+}
+
+private fun PropertiesBuilder.absoluteCenter() {
+ position = Position.absolute
+ left = 50.pct
+ top = 50.pct
+ transform = translate((-50).pct, (-50).pct)
+}
+
+private fun PropertiesBuilder.zeroMargins() {
+ margin = Margin(vertical = 0.px, horizontal = 0.px)
+ padding = Padding(vertical = 0.px, horizontal = 0.px)
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt
new file mode 100644
index 00000000..4b5eb509
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt
@@ -0,0 +1,57 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.roundToInt
+import kotlin.math.sin
+
+data class CartesianCoords(
+ val x: Int,
+ val y: Int,
+)
+
+data class PolarCoords(
+ val radius: Int,
+ val angleDeg: Int,
+)
+
+private fun Int.toRadians() = (this * PI / 180.0)
+private fun Double.project(angleRad: Double, trigFn: (Double) -> Double) = (this * trigFn(angleRad)).roundToInt()
+private fun Double.xProjection(angleRad: Double) = project(angleRad, ::cos)
+private fun Double.yProjection(angleRad: Double) = project(angleRad, ::sin)
+
+private fun PolarCoords.toCartesian() = CartesianCoords(
+ x = radius.toDouble().xProjection(angleDeg.toRadians()),
+ y = radius.toDouble().yProjection(angleDeg.toRadians()),
+)
+
+// Y-axis is pointing down in the browser, so the directions need to be reversed
+// (positive angles are now clockwise)
+enum class Direction(private val value: Int) {
+ CLOCKWISE(1),
+ COUNTERCLOCKWISE(-1);
+
+ fun toOrientedDegrees(deg: Int) = value * deg
+}
+
+data class RadialConfig(
+ val radius: Int = 120,
+ val spreadArcDegrees: Int = 360, // full circle
+ val firstItemAngleDegrees: Int = 0, // 12 o'clock
+ val direction: Direction = Direction.CLOCKWISE,
+) {
+ val diameter: Int = radius * 2
+}
+
+private const val DEFAULT_START = -90 // Up, because Y-axis is reversed
+
+fun offsetsFromCenter(nbItems: Int, radialConfig: RadialConfig = RadialConfig()): List<CartesianCoords> {
+ val startAngle = DEFAULT_START + radialConfig.direction.toOrientedDegrees(radialConfig.firstItemAngleDegrees)
+ val angleStep = radialConfig.spreadArcDegrees / nbItems
+ return List(nbItems) { itemCartesianOffsets(startAngle, angleStep, it, radialConfig) }
+}
+
+private fun itemCartesianOffsets(startAngle: Int, angleStep: Int, index: Int, config: RadialConfig): CartesianCoords {
+ val itemAngle = startAngle + config.direction.toOrientedDegrees(angleStep) * index
+ return PolarCoords(config.radius, itemAngle).toCartesian()
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt
new file mode 100644
index 00000000..645cf5f3
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt
@@ -0,0 +1,139 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import blueprintjs.core.*
+import blueprintjs.icons.*
+import csstype.*
+import emotion.react.*
+import org.luxons.sevenwonders.model.api.*
+import org.luxons.sevenwonders.model.api.actions.Icon
+import org.luxons.sevenwonders.model.wonders.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.span
+import web.cssom.*
+import web.html.*
+
+fun ChildrenBuilder.radialPlayerList(
+ players: List<PlayerDTO>,
+ currentPlayer: PlayerDTO,
+ block: HTMLAttributes<HTMLDivElement>.() -> Unit = {},
+) {
+ val playerItems = players //
+ .map { PlayerItem.Player(it) }
+ .growWithPlaceholders(targetSize = 3)
+ .withUserFirst(currentPlayer)
+
+ radialList(
+ items = playerItems,
+ centerElement = LobbyWoodenTable.create {
+ diameter = 200.px
+ borderSize = 15.px
+ },
+ renderItem = { PlayerElement.create { playerItem = it } },
+ getKey = { it.key },
+ itemWidth = 120,
+ itemHeight = 100,
+ options = RadialConfig(
+ radius = 175,
+ firstItemAngleDegrees = 180, // self at the bottom
+ direction = Direction.COUNTERCLOCKWISE, // new players sit to the right of last player
+ ),
+ block = block,
+ )
+}
+
+private fun List<PlayerItem>.growWithPlaceholders(targetSize: Int): List<PlayerItem> = when {
+ size < targetSize -> this + List(targetSize - size) { PlayerItem.Placeholder(size + it) }
+ else -> this
+}
+
+private fun List<PlayerItem>.withUserFirst(me: PlayerDTO): List<PlayerItem> {
+ val nonUsersBeginning = takeWhile { (it as? PlayerItem.Player)?.player?.username != me.username }
+ val userToEnd = subList(nonUsersBeginning.size, size)
+ return userToEnd + nonUsersBeginning
+}
+
+private sealed class PlayerItem {
+ abstract val key: String
+ abstract val playerText: String
+ abstract val opacity: Opacity
+ abstract val icon: ReactElement<*>
+
+ data class Player(val player: PlayerDTO) : PlayerItem() {
+ override val key = player.username
+ override val playerText = player.displayName
+ override val opacity = number(1.0)
+ override val icon = createUserIcon(
+ icon = player.icon ?: when {
+ player.isGameOwner -> Icon(IconNames.BADGE)
+ else -> Icon(IconNames.USER)
+ },
+ title = if (player.isGameOwner) "Game owner" else null,
+ )
+ }
+
+ data class Placeholder(val index: Int) : PlayerItem() {
+ override val key = "player-placeholder-$index"
+ override val playerText = "?"
+ override val opacity = number(0.4)
+ override val icon = createUserIcon(
+ icon = Icon(IconNames.USER),
+ title = "Waiting for player...",
+ )
+ }
+}
+
+private fun createUserIcon(icon: Icon, title: String?) = BpIcon.create {
+ this.icon = icon.name
+ this.size = 50
+ this.title = title
+}
+
+private external interface PlayerElementProps : Props {
+ var playerItem: PlayerItem
+}
+
+private val PlayerElement = FC<PlayerElementProps>(displayName = "PlayerElement") { props ->
+ val playerItem = props.playerItem
+ div {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = AlignItems.center
+ opacity = playerItem.opacity
+ }
+ child(playerItem.icon)
+ span {
+ css {
+ fontSize = if (playerItem is PlayerItem.Placeholder) 1.5.rem else 0.9.rem
+ }
+ +playerItem.playerText
+ }
+ if (playerItem is PlayerItem.Player) {
+ div {
+ val wonder = playerItem.player.wonder
+
+ css {
+ marginTop = 0.3.rem
+
+ children(".wonder-tag") {
+ color = Color("#f5f8fa") // blueprintjs dark theme color (removed by .bp4-tag)
+ backgroundColor = when (wonder.side) {
+ WonderSide.A -> NamedColor.seagreen
+ WonderSide.B -> NamedColor.darkred
+ }
+ }
+ }
+
+ BpTag {
+ round = true
+ className = ClassName("wonder-tag")
+
+ +"${wonder.name} ${wonder.side}"
+ }
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Table.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Table.kt
new file mode 100644
index 00000000..bfa43aa4
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/components/lobby/Table.kt
@@ -0,0 +1,97 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import csstype.*
+import emotion.css.*
+import emotion.react.*
+import emotion.styled.*
+import org.luxons.sevenwonders.ui.utils.*
+import react.*
+import react.dom.html.ReactHTML.div
+import web.cssom.*
+
+private val FIRE_REFLECTION_COLOR = Color("#b85e00")
+
+private external interface CircleProps : PropsWithChildren, PropsWithClassName {
+ var diameter: Length
+}
+
+private val Circle = FC<CircleProps>("Circle") { props ->
+ div {
+ css(props.className) {
+ width = props.diameter
+ height = props.diameter
+ borderRadius = 50.pct
+ }
+ child(props.children)
+ }
+}
+
+private val OverlayCircle = Circle.styled {
+ position = Position.absolute
+ top = 0.px
+ left = 0.px
+}
+
+external interface LobbyWoodenTableProps : Props {
+ var diameter: Length
+ var borderSize: Length
+}
+
+val LobbyWoodenTable = FC<LobbyWoodenTableProps>("LobbyWoodenTable") { props ->
+ Circle {
+ diameter = props.diameter
+
+ css {
+ backgroundColor = Color("#3d1e0e")
+ }
+
+ Circle {
+ diameter = props.diameter - props.borderSize
+ css {
+ position = Position.absolute
+ top = props.borderSize / 2
+ left = props.borderSize / 2
+ background = linearGradient(45.deg, Color("#88541e"), Color("#995645"), Color("#52251a"))
+ }
+ }
+
+ // flame reflection coming from bottom-right
+ OverlayCircle {
+ diameter = props.diameter
+
+ css {
+ background =
+ linearGradient((-45).deg, stop(FIRE_REFLECTION_COLOR, 10.pct), stop(NamedColor.transparent, 50.pct))
+ opacityAnimation(duration = 1.3.s)
+ }
+ }
+ // flame reflection coming from bottom-left
+ OverlayCircle {
+ diameter = props.diameter
+
+ css {
+ background =
+ linearGradient(45.deg, stop(FIRE_REFLECTION_COLOR, 20.pct), stop(NamedColor.transparent, 40.pct))
+ opacityAnimation(duration = 0.8.s)
+ }
+ }
+ }
+}
+
+private fun PropertiesBuilder.opacityAnimation(duration: Time) {
+ val keyframes = keyframes {
+ from {
+ opacity = number(0.0)
+ }
+ to {
+ opacity = number(0.35)
+ }
+ }
+ animation = Animation(
+ name = keyframes,
+ duration = duration,
+ timingFunction = cubicBezier(0.4, 0.4, 0.4, 2.0),
+ )
+ animationDirection = AnimationDirection.alternate
+ animationIterationCount = AnimationIterationCount.infinite
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/names/RandomNameGenerator.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/names/RandomNameGenerator.kt
new file mode 100644
index 00000000..393df78d
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/names/RandomNameGenerator.kt
@@ -0,0 +1,546 @@
+package org.luxons.sevenwonders.ui.names
+
+import kotlin.random.Random
+
+internal fun randomGameName(): String = gameNames.random()
+
+internal fun randomGreekName(): String {
+ val randName = prefixes.random() + suffixes.random()
+ return if (Random.nextBoolean()) randName else "$randName of ${cities.random()}"
+}
+
+private val gameNames = listOf(
+ "Age of Antiquity",
+ "Age of Civilization",
+ "Age of Discovery",
+ "Age of Empires",
+ "Age of Wonders",
+ "Ancient Capitals",
+ "Ancient Kingdoms",
+ "Ancient Wonders",
+ "Cities of Antiquity",
+ "City of Wonders",
+ "Empire Builders",
+ "Empires of the Past",
+ "Great Monuments",
+ "Legendary Cities",
+ "Legends of the Past",
+ "Lost Empires",
+ "Magnificent Monuments",
+ "Magnificent Seven",
+ "Monuments of the Past",
+ "Monuments of the World",
+ "Mythical Kingdoms",
+ "Secrets of the Past",
+ "Seven Ancient Wonders",
+ "Seven Colossi",
+ "Seven Kingdoms",
+ "Seven Marvels",
+ "Seven Wonders Adventures",
+ "Seven Wonders Chronicles",
+ "Seven Wonders Enigma",
+ "Seven Wonders Expedition",
+ "Seven Wonders Frontier",
+ "Seven Wonders Legacy",
+ "Seven Wonders Odyssey",
+ "Seven Wonders Quest",
+ "Seven Wonders Saga",
+ "Seven Wonders Treasures",
+ "Seven Wonders Voyage",
+ "Seven Wonders and Beyond",
+ "The Great Discoveries",
+ "The Legacy of Wonders",
+ "The Magic of Seven",
+ "The Marvelous Seven",
+ "The Mysteries of Antiquity",
+ "The Seven Continents",
+ "The Seven Kingdoms",
+ "The Seven Legends",
+ "The Seven Secrets",
+ "The Seven Treasures",
+ "Wonders of Nature",
+ "Wonders of the Ages",
+ "Wonders of the World",
+ "Wonders of Time",
+ "World Treasures",
+)
+
+private val prefixes =
+ listOf(
+ "Aba",
+ "Abde",
+ "Abre",
+ "Aby",
+ "Aca",
+ "Acle",
+ "Acri",
+ "Acro",
+ "Adme",
+ "Adra",
+ "Aea",
+ "Aegi",
+ "Aei",
+ "Aeo",
+ "Aese",
+ "Aeto",
+ "Aga",
+ "Age",
+ "Agi",
+ "Agri",
+ "Aia",
+ "Aka",
+ "Akti",
+ "Ala",
+ "Alco",
+ "Ale",
+ "Alka",
+ "Alki",
+ "Alo",
+ "Alphi",
+ "Ama",
+ "Ame",
+ "Ami",
+ "Amphi",
+ "Ana",
+ "Anchi",
+ "Andro",
+ "Ane",
+ "Anta",
+ "Anthe",
+ "Anti",
+ "Ape",
+ "Aphi",
+ "Apo",
+ "Arca",
+ "Arche",
+ "Arci",
+ "Arga",
+ "Ari",
+ "Arra",
+ "Arte",
+ "Asca",
+ "Asta",
+ "Asty",
+ "Atro",
+ "Atta",
+ "Aute",
+ "Bace",
+ "Bae",
+ "Bali",
+ "Bio",
+ "Boe",
+ "Bria",
+ "Care",
+ "Carpo",
+ "Casto",
+ "Cea",
+ "Cebri",
+ "Cele",
+ "Cephi",
+ "Chae",
+ "Chare",
+ "Chari",
+ "Choe",
+ "Chromi",
+ "Chryso",
+ "Cine",
+ "Cisse",
+ "Clea",
+ "Cleo",
+ "Clyto",
+ "Cnoe",
+ "Coe",
+ "Cordy",
+ "Cory",
+ "Crati",
+ "Creti",
+ "Croe",
+ "Ctea",
+ "Cyre",
+ "Dae",
+ "Dami",
+ "Damo",
+ "Dana",
+ "Daphi",
+ "Davo",
+ "Dei",
+ "Dema",
+ "Demo",
+ "Deo",
+ "Derky",
+ "Dexi",
+ "Dia",
+ "Dio",
+ "Dithy",
+ "Dore",
+ "Dori",
+ "Doro",
+ "Drya",
+ "Dymno",
+ "Eche",
+ "Eio",
+ "Ela",
+ "Elpe",
+ "Empe",
+ "Endy",
+ "Enge",
+ "Epa",
+ "Epe",
+ "Ephi",
+ "Era",
+ "Ere",
+ "Ergi",
+ "Erxa",
+ "Euca",
+ "Euche",
+ "Eudo",
+ "Eue",
+ "Euge",
+ "Euma",
+ "Eune",
+ "Eury",
+ "Euthy",
+ "Eva",
+ "Eve",
+ "Fae",
+ "Gale",
+ "Gany",
+ "Gaua",
+ "Genna",
+ "Gera",
+ "Glau",
+ "Gorgo",
+ "Gyra",
+ "Hae",
+ "Hagi",
+ "Hali",
+ "Harma",
+ "Harmo",
+ "Harpa",
+ "Hege",
+ "Heira",
+ "Heiro",
+ "Helge",
+ "Heli",
+ "Hera",
+ "Hermo",
+ "Hiero",
+ "Hippo",
+ "Hya",
+ "Hype",
+ "Hyrca",
+ "Iatro",
+ "Iby",
+ "Ica",
+ "Ido",
+ "Illy",
+ "Ina",
+ "Iphi",
+ "Iro",
+ "Isa",
+ "Isma",
+ "Iso",
+ "Ithe",
+ "Kae",
+ "Kale",
+ "Kalli",
+ "Kame",
+ "Kapa",
+ "Kari",
+ "Karo",
+ "Kau",
+ "Keo",
+ "Kera",
+ "Kleo",
+ "Krini",
+ "Krito",
+ "Labo",
+ "Lae",
+ "Lama",
+ "Lamu",
+ "Lao",
+ "Laso",
+ "Lea",
+ "Lei",
+ "Leo",
+ "Linu",
+ "Luko",
+ "Lyca",
+ "Lyco",
+ "Lysa",
+ "Lysi",
+ "Maca",
+ "Macha",
+ "Mae",
+ "Maia",
+ "Maka",
+ "Male",
+ "Mante",
+ "Marci",
+ "Marsy",
+ "Mega",
+ "Megi",
+ "Mela",
+ "Mele",
+ "Metho",
+ "Midy",
+ "Mise",
+ "Mono",
+ "Morsi",
+ "Myrsi",
+ "Naste",
+ "Nausi",
+ "Nea",
+ "Nele",
+ "Neri",
+ "Nica",
+ "Nico",
+ "Nire",
+ "Nomi",
+ "Nycti",
+ "Oche",
+ "Ocho",
+ "Oea",
+ "Oene",
+ "Oeno",
+ "Oile",
+ "Ona",
+ "One",
+ "Ophe",
+ "Ori",
+ "Orsi",
+ "Ory",
+ "Pae",
+ "Pala",
+ "Pana",
+ "Pandi",
+ "Pani",
+ "Panta",
+ "Para",
+ "Pata",
+ "Peiri",
+ "Pele",
+ "Peli",
+ "Peri",
+ "Phae",
+ "Phala",
+ "Philo",
+ "Phyla",
+ "Poe",
+ "Poly",
+ "Praxi",
+ "Prota",
+ "Pryta",
+ "Saby",
+ "Saty",
+ "Scama",
+ "Scytha",
+ "Sele",
+ "Sila",
+ "Simo",
+ "Sisy",
+ "Sopho",
+ "Stesa",
+ "Sya",
+ "Sylo",
+ "Syne",
+ "Tala",
+ "Teba",
+ "Tele",
+ "Tene",
+ "Theo",
+ "Therse",
+ "Thrasy",
+ "Tima",
+ "Tiry",
+ "Trio",
+ "Xanthi",
+ "Xena",
+ "Xeno",
+ )
+
+private val suffixesMale =
+ listOf(
+ "ndros",
+ "bios",
+ "bulos",
+ "chus",
+ "cles",
+ "cydes",
+ "damos",
+ "dides",
+ "don",
+ "doros",
+ "dotus",
+ "gnis",
+ "goras",
+ "kles",
+ "kos",
+ "krates",
+ "laktos",
+ "laus",
+ "leon",
+ "llias",
+ "llos",
+ "llus",
+ "machos",
+ "machus",
+ "menes",
+ "menos",
+ "mos",
+ "ndius",
+ "nes",
+ "neus",
+ "nidas",
+ "nides",
+ "nos",
+ "nthius",
+ "patros",
+ "phanes",
+ "phantes",
+ "phimus",
+ "phnus",
+ "phon",
+ "phoros",
+ "phorus",
+ "phus",
+ "pides",
+ "pompos",
+ "pompus",
+ "pon",
+ "ppos",
+ "rax",
+ "reas",
+ "rides",
+ "ros",
+ "sias",
+ "sides",
+ "sius",
+ "stius",
+ "stor",
+ "stos",
+ "stus",
+ "talos",
+ "thenes",
+ "theus",
+ "tios",
+ )
+
+private val suffixesFemale =
+ listOf(
+ "ndria",
+ "boea",
+ "casta",
+ "caste",
+ "cheia",
+ "chis",
+ "cleia",
+ "dee",
+ "deia",
+ "dike",
+ "dina",
+ "doce",
+ "dora",
+ "dusa",
+ "gaea",
+ "kia",
+ "laia",
+ "lea",
+ "line",
+ "llis",
+ "lope",
+ "mache",
+ "mathe",
+ "meda",
+ "mede",
+ "meia",
+ "mela",
+ "mene",
+ "mere",
+ "mia",
+ "mina",
+ "mpias",
+ "ndra",
+ "ne",
+ "neira",
+ "nessa",
+ "nia",
+ "nice",
+ "niera",
+ "nike",
+ "nippe",
+ "nna",
+ "nome",
+ "nope",
+ "nta",
+ "nthia",
+ "pe",
+ "phae",
+ "phana",
+ "phane",
+ "phile",
+ "phobe",
+ "phone",
+ "pia",
+ "polis",
+ "pris",
+ "pyle",
+ "reia",
+ "rine",
+ "ris",
+ "rista",
+ "rpia",
+ "sia",
+ "ssa",
+ "steia",
+ "stis",
+ "syne",
+ "ta",
+ "tea",
+ "thea",
+ "theia",
+ "thia",
+ "thippe",
+ "thra",
+ "thusa",
+ "thyia",
+ "tis",
+ "trite",
+ )
+
+private val suffixes = suffixesMale + suffixesFemale
+
+private val cities =
+ listOf(
+ "Argos",
+ "Assos",
+ "Astypalaia",
+ "Carystus",
+ "Chalcis",
+ "Chios",
+ "Corfu",
+ "Corinth",
+ "Eretria",
+ "Erythrae",
+ "Karpathos",
+ "Kasos",
+ "Kos",
+ "Leros",
+ "Lindos",
+ "Marathon",
+ "Megara",
+ "Miletus",
+ "Mytilene",
+ "Naxos",
+ "Oenoe",
+ "Paros",
+ "Patmos",
+ "Patras",
+ "Phocis",
+ "Rhodes",
+ "Salamis",
+ "Skiathos",
+ "Sparta",
+ "Thasos",
+ "Thebes",
+ )
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
new file mode 100644
index 00000000..b0c56a79
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
@@ -0,0 +1,32 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.TurnAction
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.events.GameListEvent
+import org.luxons.sevenwonders.model.cards.PreparedCard
+import redux.RAction
+
+data class FatalError(val message: String) : RAction
+
+data class SetCurrentPlayerAction(val player: ConnectedPlayer) : RAction
+
+data class UpdateGameListAction(val event: GameListEvent) : RAction
+
+data class UpdateLobbyAction(val lobby: LobbyDTO) : RAction
+
+data class EnterLobbyAction(val lobby: LobbyDTO) : RAction
+
+object LeaveLobbyAction : RAction
+
+data class EnterGameAction(val lobby: LobbyDTO, val turnInfo: PlayerTurnInfo<TurnAction.SayReady>) : RAction
+
+data class TurnInfoEvent(val turnInfo: PlayerTurnInfo<*>) : RAction
+
+data class PreparedMoveEvent(val move: PlayerMove) : RAction
+
+data class PreparedCardEvent(val card: PreparedCard) : RAction
+
+data class PlayerReadyEvent(val username: String) : RAction
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
new file mode 100644
index 00000000..87bacf62
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
@@ -0,0 +1,34 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.Settings
+import org.luxons.sevenwonders.model.wonders.AssignedWonder
+import redux.RAction
+
+data class RequestChooseName(val playerName: String) : RAction
+
+data class RequestCreateGame(val gameName: String) : RAction
+
+data class RequestJoinGame(val gameId: Long) : RAction
+
+data class RequestAddBot(val botDisplayName: String) : RAction
+
+data class RequestReorderPlayers(val orderedPlayers: List<String>) : RAction
+
+data class RequestReassignWonders(val wonders: List<AssignedWonder>) : RAction
+
+data class RequestUpdateSettings(val settings: Settings) : RAction
+
+class RequestStartGame : RAction
+
+class RequestLeaveLobby : RAction
+
+class RequestDisbandLobby : RAction
+
+class RequestLeaveGame : RAction
+
+class RequestSayReady : RAction
+
+data class RequestPrepareMove(val move: PlayerMove) : RAction
+
+class RequestUnprepareMove : RAction
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
new file mode 100644
index 00000000..e79b063e
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
@@ -0,0 +1,95 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.client.GameState
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.model.api.events.GameListEvent
+import redux.RAction
+
+data class SwState(
+ val connectedPlayer: ConnectedPlayer? = null,
+ // they must be by ID to support updates to a sublist
+ val gamesById: Map<Long, LobbyDTO> = emptyMap(),
+ val currentLobby: LobbyDTO? = null,
+ val gameState: GameState? = null,
+ val fatalError: String? = null,
+) {
+ val currentPlayer: PlayerDTO? = (gameState?.players ?: currentLobby?.players)?.first {
+ it.username == connectedPlayer?.username
+ }
+ val games: List<LobbyDTO> = gamesById.values.toList()
+}
+
+fun rootReducer(state: SwState, action: RAction): SwState = state.copy(
+ gamesById = gamesReducer(state.gamesById, action),
+ connectedPlayer = currentPlayerReducer(state.connectedPlayer, action),
+ currentLobby = currentLobbyReducer(state.currentLobby, action),
+ gameState = gameStateReducer(state.gameState, action),
+ fatalError = connectionLostReducer(action),
+)
+
+private fun gamesReducer(games: Map<Long, LobbyDTO>, action: RAction): Map<Long, LobbyDTO> = when (action) {
+ is UpdateGameListAction -> when (action.event) {
+ is GameListEvent.ReplaceList -> action.event.lobbies.associateBy { it.id }
+ is GameListEvent.CreateOrUpdate -> games + (action.event.lobby.id to action.event.lobby)
+ is GameListEvent.Delete -> games - action.event.lobbyId
+ }
+ else -> games
+}
+
+private fun currentPlayerReducer(currentPlayer: ConnectedPlayer?, action: RAction): ConnectedPlayer? = when (action) {
+ is SetCurrentPlayerAction -> action.player
+ else -> currentPlayer
+}
+
+private fun currentLobbyReducer(currentLobby: LobbyDTO?, action: RAction): LobbyDTO? = when (action) {
+ is EnterLobbyAction -> action.lobby
+ is LeaveLobbyAction -> null
+ is UpdateLobbyAction -> action.lobby
+ is PlayerReadyEvent -> currentLobby?.let { l ->
+ l.copy(players = l.players.map { p -> if (p.username == action.username) p.copy(isReady = true) else p })
+ }
+ else -> currentLobby
+}
+
+private fun gameStateReducer(gameState: GameState?, action: RAction): GameState? = when (action) {
+ is EnterGameAction -> GameState(
+ gameId = action.lobby.id,
+ players = action.lobby.players,
+ playerIndex = action.turnInfo.playerIndex,
+ currentAge = action.turnInfo.table.currentAge,
+ boards = action.turnInfo.table.boards,
+ handRotationDirection = action.turnInfo.table.handRotationDirection,
+ action = action.turnInfo.action,
+ preparedCardsByUsername = emptyMap(),
+ currentPreparedMove = null,
+ )
+ is PreparedMoveEvent -> gameState?.copy(currentPreparedMove = action.move)
+ is RequestUnprepareMove -> gameState?.copy(currentPreparedMove = null)
+ is PreparedCardEvent -> gameState?.copy(
+ preparedCardsByUsername = gameState.preparedCardsByUsername + (action.card.username to action.card.cardBack),
+ )
+ is PlayerReadyEvent -> gameState?.copy(
+ players = gameState.players.map { p ->
+ if (p.username == action.username) p.copy(isReady = true) else p
+ },
+ )
+ is TurnInfoEvent -> gameState?.copy(
+ players = gameState.players.map { p -> p.copy(isReady = false) },
+ playerIndex = action.turnInfo.playerIndex,
+ currentAge = action.turnInfo.table.currentAge,
+ boards = action.turnInfo.table.boards,
+ handRotationDirection = action.turnInfo.table.handRotationDirection,
+ action = action.turnInfo.action,
+ preparedCardsByUsername = emptyMap(),
+ currentPreparedMove = null,
+ )
+ is LeaveLobbyAction -> null
+ else -> gameState
+}
+
+private fun connectionLostReducer(action: RAction): String? = when (action) {
+ is FatalError -> action.message
+ else -> null
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
new file mode 100644
index 00000000..71c5eec0
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
@@ -0,0 +1,29 @@
+package org.luxons.sevenwonders.ui.redux
+
+import kotlinx.browser.window
+import org.luxons.sevenwonders.ui.redux.sagas.SagaManager
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
+import redux.applyMiddleware
+import redux.compose
+import redux.createStore
+import redux.rEnhancer
+
+val INITIAL_STATE = SwState()
+
+private fun <A, T1, R> composeWithDevTools(function1: (T1) -> R, function2: (A) -> T1): (A) -> R {
+ val reduxDevtoolsExtensionCompose = window.asDynamic().__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
+ if (reduxDevtoolsExtensionCompose == undefined) {
+ return compose(function1, function2)
+ }
+ return reduxDevtoolsExtensionCompose(function1, function2) as Function1<A, R>
+}
+
+fun configureStore(
+ sagaManager: SagaManager<SwState, RAction, WrapperAction>,
+ initialState: SwState = INITIAL_STATE,
+): Store<SwState, RAction, WrapperAction> {
+ val sagaEnhancer = applyMiddleware(sagaManager.createMiddleware())
+ return createStore(::rootReducer, initialState, composeWithDevTools(sagaEnhancer, rEnhancer()))
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt
new file mode 100644
index 00000000..eb182dc7
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt
@@ -0,0 +1,31 @@
+package org.luxons.sevenwonders.ui.redux
+
+import react.*
+import react.redux.*
+import redux.*
+import kotlin.reflect.*
+
+fun <R> useSwSelector(selector: (SwState) -> R) = useSelector(selector)
+fun useSwDispatch() = useDispatch<RAction, WrapperAction>()
+
+fun <SP : Props, DP : Props, P : Props> connectStateAndDispatch(
+ clazz: KClass<out Component<P, out State>>,
+ mapStateToProps: SP.(SwState, Props) -> Unit,
+ mapDispatchToProps: DP.((RAction) -> WrapperAction, Props) -> Unit,
+): ComponentClass<Props> = connectStateAndDispatch(
+ component = clazz.react,
+ mapStateToProps = mapStateToProps,
+ mapDispatchToProps = mapDispatchToProps,
+)
+
+fun <SP : Props, DP : Props, P : Props> connectStateAndDispatch(
+ component: ComponentClass<P>,
+ mapStateToProps: SP.(SwState, Props) -> Unit,
+ mapDispatchToProps: DP.((RAction) -> WrapperAction, Props) -> Unit,
+): ComponentClass<Props> {
+ val connect = rConnect<SwState, RAction, WrapperAction, Props, SP, DP, P>(
+ mapStateToProps = mapStateToProps,
+ mapDispatchToProps = mapDispatchToProps,
+ )
+ return connect.invoke(component)
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt
new file mode 100644
index 00000000..3343e62e
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/RouteBasedSagas.kt
@@ -0,0 +1,44 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.flow.map
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.router.Navigate
+import org.luxons.sevenwonders.ui.router.SwRoute
+
+suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) {
+ // browser navigation could have brought us here: we should leave the game/lobby
+ ensureNoCurrentGameNorLobby(session)
+ session.watchGames()
+ .map { UpdateGameListAction(it) }
+ .collect { dispatch(it) }
+}
+
+private suspend fun SwSagaContext.ensureNoCurrentGameNorLobby(session: SevenWondersSession) {
+ if (reduxState.gameState != null) {
+ console.warn("User left a game via browser navigation, telling the server...")
+ session.leaveGame()
+ } else if (reduxState.currentLobby != null) {
+ console.warn("User left the lobby via browser navigation, telling the server...")
+ session.leaveLobby()
+ }
+}
+
+suspend fun SwSagaContext.lobbySaga(session: SevenWondersSession) {
+ if (reduxState.gameState != null) {
+ console.warn("User left a game via browser navigation, telling the server...")
+ session.leaveGame()
+ } else if (reduxState.currentLobby == null) {
+ console.warn("User went to lobby page via browser navigation, redirecting to game browser...")
+ dispatch(Navigate(SwRoute.GAME_BROWSER))
+ }
+}
+
+suspend fun SwSagaContext.gameSaga(session: SevenWondersSession) {
+ if (reduxState.gameState == null) {
+ // TODO properly redirect somewhere
+ error("Game saga run without a current game")
+ }
+ // notifies the server that the client is ready to receive the first hand
+ session.sayReady()
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
new file mode 100644
index 00000000..2ad98c8e
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
@@ -0,0 +1,131 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.browser.window
+import kotlinx.coroutines.*
+import org.hildan.krossbow.stomp.ConnectionException
+import org.hildan.krossbow.stomp.MissingHeartBeatException
+import org.hildan.krossbow.stomp.WebSocketClosedUnexpectedly
+import org.luxons.sevenwonders.client.*
+import org.luxons.sevenwonders.model.api.events.GameEvent
+import org.luxons.sevenwonders.ui.redux.*
+import org.luxons.sevenwonders.ui.router.Navigate
+import org.luxons.sevenwonders.ui.router.SwRoute
+import org.luxons.sevenwonders.ui.router.routerSaga
+import redux.RAction
+import redux.WrapperAction
+import webpack.isProdEnv
+
+typealias SwSagaContext = SagaContext<SwState, RAction, WrapperAction>
+
+suspend fun SwSagaContext.rootSaga() = try {
+ coroutineScope {
+ val action = next<RequestChooseName>()
+ val serverUrl = sevenWondersWebSocketUrl()
+ console.info("Connecting to Seven Wonders web socket API...")
+ val session = SevenWondersClient().connect(serverUrl)
+ console.info("Connected!")
+
+ launch(start = CoroutineStart.UNDISPATCHED) {
+ serverErrorSaga(session)
+ }
+
+ launchApiActionHandlersIn(this, session)
+ launchApiEventHandlersIn(this, session)
+
+ val player = session.chooseNameAndAwait(action.playerName)
+ dispatch(SetCurrentPlayerAction(player))
+
+ routerSaga(SwRoute.GAME_BROWSER) {
+ when (it) {
+ SwRoute.HOME -> Unit
+ SwRoute.LOBBY -> lobbySaga(session)
+ SwRoute.GAME_BROWSER -> gameBrowserSaga(session)
+ SwRoute.GAME -> gameSaga(session)
+ }
+ }
+ }
+} catch (e: Exception) {
+ console.error(e)
+ dispatchFatalError(e)
+}
+
+private fun SwSagaContext.dispatchFatalError(throwable: Throwable) {
+ when (throwable) {
+ is ConnectionException -> dispatch(FatalError(throwable.message ?: "Couldn't connect to the server."))
+ is MissingHeartBeatException -> dispatch(FatalError("The server doesn't seem to be responding."))
+ is WebSocketClosedUnexpectedly -> dispatch(FatalError("The connection to the server was closed unexpectedly."))
+ else -> dispatch(FatalError("An unexpected error occurred: ${throwable.message}"))
+ }
+}
+
+private fun sevenWondersWebSocketUrl(): String {
+ if (!isProdEnv()) {
+ return "ws://localhost:8000"
+ }
+ // prevents mixed content requests
+ val scheme = if (window.location.protocol.startsWith("https")) "wss" else "ws"
+ return "$scheme://${window.location.host}"
+}
+
+private suspend fun serverErrorSaga(session: SevenWondersSession) {
+ session.watchErrors().collect { err ->
+ // These are not an error for the user, but rather for the programmer
+ console.error("${err.code}: ${err.message}")
+ console.error(JSON.stringify(err))
+ }
+}
+
+private fun SwSagaContext.launchApiActionHandlersIn(scope: CoroutineScope, session: SevenWondersSession) {
+ scope.launchOnEach<RequestChooseName> { session.chooseName(it.playerName) }
+
+ scope.launchOnEach<RequestCreateGame> { session.createGame(it.gameName) }
+ scope.launchOnEach<RequestJoinGame> { session.joinGame(it.gameId) }
+ scope.launchOnEach<RequestLeaveLobby> { session.leaveLobby() }
+ scope.launchOnEach<RequestDisbandLobby> { session.disbandLobby() }
+
+ scope.launchOnEach<RequestAddBot> { session.addBot(it.botDisplayName) }
+ scope.launchOnEach<RequestReorderPlayers> { session.reorderPlayers(it.orderedPlayers) }
+ scope.launchOnEach<RequestReassignWonders> { session.reassignWonders(it.wonders) }
+ scope.launchOnEach<RequestStartGame> { session.startGame() }
+
+ scope.launchOnEach<RequestSayReady> { session.sayReady() }
+ scope.launchOnEach<RequestPrepareMove> { session.prepareMove(it.move) }
+ scope.launchOnEach<RequestUnprepareMove> { session.unprepareMove() }
+ scope.launchOnEach<RequestLeaveGame> { session.leaveGame() }
+}
+
+private fun SwSagaContext.launchApiEventHandlersIn(scope: CoroutineScope, session: SevenWondersSession) {
+ scope.launch {
+ session.watchGameEvents().collect { event ->
+ when (event) {
+ is GameEvent.NameChosen -> {
+ dispatch(SetCurrentPlayerAction(event.player))
+ dispatch(Navigate(SwRoute.GAME_BROWSER))
+ }
+ is GameEvent.LobbyJoined -> {
+ dispatch(EnterLobbyAction(event.lobby))
+ dispatch(Navigate(SwRoute.LOBBY))
+ }
+ is GameEvent.LobbyUpdated -> {
+ dispatch(UpdateLobbyAction(event.lobby))
+ }
+ GameEvent.LobbyLeft -> {
+ dispatch(LeaveLobbyAction)
+ dispatch(Navigate(SwRoute.GAME_BROWSER))
+ }
+ is GameEvent.GameStarted -> {
+ val currentLobby = reduxState.currentLobby ?: error("Received game started event without being in a lobby")
+ dispatch(EnterGameAction(currentLobby, event.turnInfo))
+ dispatch(Navigate(SwRoute.GAME))
+ }
+ is GameEvent.NewTurnStarted -> dispatch(TurnInfoEvent(event.turnInfo))
+ is GameEvent.MovePrepared -> dispatch(PreparedMoveEvent(event.move))
+ is GameEvent.CardPrepared -> dispatch(PreparedCardEvent(event.preparedCard))
+ is GameEvent.PlayerIsReady -> dispatch(PlayerReadyEvent(event.username))
+ // Currently the move is already unprepared when launching the unprepare request
+ // TODO add a "unpreparing" state and only update redux when the move is successfully unprepared
+ GameEvent.MoveUnprepared -> {}
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
new file mode 100644
index 00000000..05c03b13
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
@@ -0,0 +1,106 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.*
+import kotlinx.coroutines.flow.*
+import redux.Middleware
+import redux.MiddlewareApi
+import redux.RAction
+
+class SagaManager<S, A : RAction, R>(
+ private val monitor: ((A) -> Unit)? = null,
+) {
+ private lateinit var context: SagaContext<S, A, R>
+
+ private val actions = MutableSharedFlow<A>(extraBufferCapacity = Channel.UNLIMITED)
+
+ fun createMiddleware(): Middleware<S, A, R, A, R> = ::sagasMiddleware
+
+ private fun sagasMiddleware(api: MiddlewareApi<S, A, R>): ((A) -> R) -> (A) -> R {
+ context = SagaContext(api, actions)
+ return { nextDispatch ->
+ { action ->
+ onActionDispatched(action)
+ val result = nextDispatch(action)
+ handleAction(action)
+ result
+ }
+ }
+ }
+
+ private fun onActionDispatched(action: A) {
+ monitor?.invoke(action)
+ }
+
+ private fun handleAction(action: A) {
+ val emitted = actions.tryEmit(action)
+ if (!emitted) {
+ // should never happen since our buffer is 'unlimited' (in reality it's Int.MAX_VALUE)
+ error("Couldn't dispatch redux action, buffer is full")
+ }
+ }
+
+ fun launchSaga(coroutineScope: CoroutineScope, saga: suspend SagaContext<S, A, R>.() -> Unit): Job {
+ checkMiddlewareApplied()
+ return coroutineScope.launch {
+ context.saga()
+ }
+ }
+
+ suspend fun runSaga(saga: suspend SagaContext<S, A, R>.() -> Unit) {
+ checkMiddlewareApplied()
+ context.saga()
+ }
+
+ private fun checkMiddlewareApplied() {
+ check(::context.isInitialized) {
+ "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware"
+ }
+ }
+}
+
+class SagaContext<S, A : RAction, R>(
+ private val reduxApi: MiddlewareApi<S, A, R>,
+ val reduxActions: SharedFlow<A>,
+) {
+ /**
+ * The current redux state.
+ */
+ val reduxState: S
+ get() = reduxApi.getState()
+
+ /**
+ * Dispatches the given redux [action].
+ */
+ fun dispatch(action: A) {
+ reduxApi.dispatch(action)
+ }
+
+ /**
+ * Executes [handle] on every action dispatched of the type [T]. This runs forever until the current coroutine is
+ * cancelled.
+ */
+ suspend inline fun <reified T : A> onEach(
+ crossinline handle: suspend SagaContext<S, A, R>.(T) -> Unit,
+ ) {
+ reduxActions.filterIsInstance<T>().collect { handle(it) }
+ }
+
+ /**
+ * Launches a coroutine in the receiver scope that executes [handle] on every action dispatched of the type [T].
+ * The returned [Job] can be used to cancel that coroutine (just like a regular [launch])
+ */
+ inline fun <reified T : A> CoroutineScope.launchOnEach(
+ crossinline handle: suspend SagaContext<S, A, R>.(T) -> Unit,
+ ): Job = launch { onEach(handle) }
+
+ /**
+ * Suspends until the next action matching the given [predicate] is dispatched, and returns that action.
+ */
+ suspend fun next(predicate: (A) -> Boolean): A = reduxActions.first { predicate(it) }
+
+ /**
+ * Suspends until the next action of type [T] is dispatched, and returns that action.
+ */
+ suspend inline fun <reified T : A> next(): T = reduxActions.filterIsInstance<T>().first()
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/router/Router.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/router/Router.kt
new file mode 100644
index 00000000..1a0840cf
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/router/Router.kt
@@ -0,0 +1,48 @@
+package org.luxons.sevenwonders.ui.router
+
+import kotlinx.browser.window
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.ui.redux.sagas.SwSagaContext
+import redux.RAction
+
+enum class SwRoute(val path: String) {
+ HOME("/"),
+ GAME_BROWSER("/games"),
+ LOBBY("/lobby"),
+ GAME("/game");
+
+ companion object {
+ private val all = values().associateBy { it.path }
+
+ fun from(path: String) = all.getValue(path)
+ }
+}
+
+data class Navigate(val route: SwRoute) : RAction
+
+suspend fun SwSagaContext.routerSaga(
+ startRoute: SwRoute,
+ runRouteSaga: suspend SwSagaContext.(SwRoute) -> Unit,
+) {
+ coroutineScope {
+ window.location.hash = startRoute.path
+ launch { changeRouteOnNavigateAction() }
+ var currentSaga: Job = launch { runRouteSaga(startRoute) }
+ window.onhashchange = { event ->
+ val route = SwRoute.from(event.newURL.substringAfter("#"))
+ currentSaga.cancel()
+ currentSaga = this@coroutineScope.launch {
+ runRouteSaga(route)
+ }
+ Unit
+ }
+ }
+}
+
+suspend fun SwSagaContext.changeRouteOnNavigateAction() {
+ onEach<Navigate> {
+ window.location.hash = it.route.path
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt
new file mode 100644
index 00000000..600f08d3
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt
@@ -0,0 +1,15 @@
+package org.luxons.sevenwonders.ui.utils
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.selects.select
+
+// Cannot inline or it crashes for some reason
+suspend fun <R> awaitFirst(f1: suspend () -> R, f2: suspend () -> R): R = coroutineScope {
+ val deferred1 = async { f1() }
+ val deferred2 = async { f2() }
+ select<R> {
+ deferred1.onAwait { deferred2.cancel(); it }
+ deferred2.onAwait { deferred1.cancel(); it }
+ }
+}
diff --git a/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/StyleUtils.kt b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/StyleUtils.kt
new file mode 100644
index 00000000..7ca67be4
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/org/luxons/sevenwonders/ui/utils/StyleUtils.kt
@@ -0,0 +1,43 @@
+package org.luxons.sevenwonders.ui.utils
+
+import csstype.*
+import js.core.*
+import react.dom.html.*
+import web.cssom.*
+
+/**
+ * The cubic-bezier() function defines a Cubic Bezier curve.
+ *
+ * A Cubic Bezier curve is defined by four points P0, P1, P2, and P3. P0 and P3 are the start and the end of the curve
+ * and, in CSS these points are fixed as the coordinates are ratios. P0 is (0, 0) and represents the initial time and
+ * the initial state, P3 is (1, 1) and represents the final time and the final state.
+ *
+ * The x coordinates provided here must be between 0 and 1 (the bezier curve points should be between the start time
+ * and end time, giving other values would make the curve go back in the past or further into the future).
+ *
+ * The y coordinates may be any value: the intermediate states can be below or above the start (0) or end (1) values.
+ */
+fun cubicBezier(x1: Double, y1: Double, x2: Double, y2: Double) =
+ "cubic-bezier($x1, $y1, $x2, $y2)".unsafeCast<AnimationTimingFunction>()
+
+fun Margin(all: AutoLength) = Margin(vertical = all, horizontal = all)
+
+fun Padding(all: Length) = Padding(vertical = all, horizontal = all)
+
+// this should work because NamedColor is ultimately a hex string in JS, not the actual name
+fun NamedColor.withAlpha(alpha: Double) = "$this${(alpha * 255).toInt().toString(16)}".unsafeCast<BackgroundColor>()
+
+operator fun FilterFunction.plus(other: FilterFunction) = "$this $other".unsafeCast<FilterFunction>()
+
+fun PropertiesBuilder.ancestorHover(selector: String, block: PropertiesBuilder.() -> Unit) =
+ "$selector:hover &".invoke(block)
+
+fun PropertiesBuilder.children(selector: String, block: PropertiesBuilder.() -> Unit) =
+ "& > $selector".invoke(block)
+
+fun PropertiesBuilder.descendants(selector: String, block: PropertiesBuilder.() -> Unit) =
+ "& $selector".invoke(block)
+
+fun HTMLAttributes<*>.inlineStyles(block: PropertiesBuilder.() -> Unit) {
+ style = jso(block)
+}
diff --git a/sw-ui/src/jsMain/kotlin/webpack/WebpackUtils.kt b/sw-ui/src/jsMain/kotlin/webpack/WebpackUtils.kt
new file mode 100644
index 00000000..dde1140a
--- /dev/null
+++ b/sw-ui/src/jsMain/kotlin/webpack/WebpackUtils.kt
@@ -0,0 +1,9 @@
+package webpack
+
+external val process: Process
+
+external interface Process {
+ val env: dynamic
+}
+
+fun isProdEnv(): Boolean = process.env.NODE_ENV == "production"
bgstack15