diff options
Diffstat (limited to 'sw-ui/src/main/kotlin/org/luxons')
32 files changed, 2082 insertions, 0 deletions
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt new file mode 100644 index 00000000..8b38e010 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt @@ -0,0 +1,49 @@ +package org.luxons.sevenwonders.ui + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.luxons.sevenwonders.ui.components.application +import org.luxons.sevenwonders.ui.redux.SwState +import org.luxons.sevenwonders.ui.redux.configureStore +import org.luxons.sevenwonders.ui.redux.sagas.SagaManager +import org.luxons.sevenwonders.ui.redux.sagas.rootSaga +import org.w3c.dom.Element +import react.dom.* +import react.redux.provider +import redux.RAction +import redux.Store +import redux.WrapperAction +import kotlin.browser.document +import kotlin.browser.window + +fun main() { + window.onload = { + val rootElement = document.getElementById("root") + if (rootElement != null) { + initializeAndRender(rootElement) + } else { + console.error("Element with ID 'root' was not found, cannot bootstrap react app") + } + } +} + +private fun initializeAndRender(rootElement: Element) { + val store = initRedux() + + render(rootElement) { + provider(store) { + application() + } + } +} + +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/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt new file mode 100644 index 00000000..b1244b5c --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt @@ -0,0 +1,22 @@ +package org.luxons.sevenwonders.ui.components + +import org.luxons.sevenwonders.ui.components.game.gameScene +import org.luxons.sevenwonders.ui.components.gameBrowser.gameBrowser +import org.luxons.sevenwonders.ui.components.home.home +import org.luxons.sevenwonders.ui.components.lobby.lobby +import org.luxons.sevenwonders.ui.router.Route +import react.RBuilder +import react.router.dom.hashRouter +import react.router.dom.redirect +import react.router.dom.route +import react.router.dom.switch + +fun RBuilder.application() = hashRouter { + switch { + route(Route.GAME_BROWSER.path) { gameBrowser() } + route(Route.GAME.path) { gameScene() } + route(Route.LOBBY.path) { lobby() } + route(Route.HOME.path, exact = true) { home() } + redirect(from = "*", to = "/") + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt new file mode 100644 index 00000000..f5b16248 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt @@ -0,0 +1,36 @@ +package org.luxons.sevenwonders.ui.components + +import kotlinx.css.Overflow +import kotlinx.css.Position +import kotlinx.css.bottom +import kotlinx.css.left +import kotlinx.css.overflow +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.transform +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.right +import kotlinx.css.top +import styled.StyleSheet + +object GlobalStyles : StyleSheet("GlobalStyles", isStatic = true) { + + val fullscreen by css { + position = Position.fixed + top = 0.px + left = 0.px + bottom = 0.px + right = 0.px + overflow = Overflow.hidden + } + + val fixedCenter by css { + position = Position.fixed + left = 50.pct + top = 50.pct + transform { + translate((-50).pct, (-50).pct) + } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt new file mode 100644 index 00000000..dd67757a --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt @@ -0,0 +1,124 @@ +package org.luxons.sevenwonders.ui.components.game + +import kotlinx.css.Color +import kotlinx.css.Display +import kotlinx.css.Position +import kotlinx.css.TextAlign +import kotlinx.css.display +import kotlinx.css.height +import kotlinx.css.margin +import kotlinx.css.maxHeight +import kotlinx.css.maxWidth +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.boxShadow +import kotlinx.css.properties.transform +import kotlinx.css.properties.translate +import kotlinx.css.rem +import kotlinx.css.textAlign +import kotlinx.css.vh +import kotlinx.css.vw +import kotlinx.css.width +import kotlinx.css.zIndex +import kotlinx.html.DIV +import kotlinx.html.title +import org.luxons.sevenwonders.model.boards.Board +import org.luxons.sevenwonders.model.cards.TableCard +import org.luxons.sevenwonders.model.wonders.ApiWonder +import react.RBuilder +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv +import styled.styledImg + +// card offsets in % of their size when displayed in columns +private const val xOffset = 20 +private const val yOffset = 21 + +fun RBuilder.boardComponent(board: Board) { + styledDiv { + css { + width = 100.vw + } + tableCards(cardColumns = board.playedCards) + wonderComponent(wonder = board.wonder) + } +} + +private fun RBuilder.tableCards(cardColumns: List<List<TableCard>>) { + styledDiv { + css { + display = Display.flex + height = 40.vh + width = 100.vw + } + cardColumns.forEach { cards -> + tableCardColumn(cards = cards) { + attrs { + key = cards.first().color.toString() + } + } + } + } +} + +private fun RBuilder.tableCardColumn(cards: List<TableCard>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + css { + height = 40.vh + width = 15.vw + margin = "auto" + position = Position.relative + } + block() + cards.forEachIndexed { index, card -> + tableCard(card = card, indexInColumn = index) { + attrs { key = card.name } + } + } + } +} + +private fun RBuilder.tableCard(card: TableCard, indexInColumn: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + css { + position = Position.absolute + zIndex = indexInColumn + transform { + translate( + tx = (indexInColumn * xOffset).pct, + ty = (indexInColumn * yOffset).pct + ) + } + } + block() + val highlightColor = if (card.playedDuringLastMove) Color.gold else null + cardImage(card = card, highlightColor = highlightColor) { + css { + maxWidth = 10.vw + maxHeight = 25.vh + } + } + } +} + +private fun RBuilder.wonderComponent(wonder: ApiWonder) { + styledDiv { + css { + width = 100.vw + textAlign = TextAlign.center + } + styledImg(src="/images/wonders/${wonder.image}") { + css { + declarations["border-radius"] = "0.5%/1.5%" + boxShadow(color = Color.black, offsetX = 0.2.rem, offsetY = 0.2.rem, blurRadius = 0.5.rem) + maxHeight = 30.vh + maxWidth = 95.vw + } + attrs { + this.title = wonder.name + this.alt = "Wonder ${wonder.name}" + } + } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt new file mode 100644 index 00000000..38afe028 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt @@ -0,0 +1,43 @@ +package org.luxons.sevenwonders.ui.components.game + +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.borderRadius +import kotlinx.css.pct +import kotlinx.css.properties.boxShadow +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.html.IMG +import kotlinx.html.title +import org.luxons.sevenwonders.model.cards.Card +import react.RBuilder +import styled.StyledDOMBuilder +import styled.css +import styled.styledImg + +fun RBuilder.cardImage(card: Card, highlightColor: Color? = null, block: StyledDOMBuilder<IMG>.() -> Unit = {}) { + styledImg(src = "/images/cards/${card.image}") { + css { + borderRadius = 5.pct + boxShadow(offsetX = 2.px, offsetY = 2.px, blurRadius = 5.px, color = Color.black) + highlightStyle(highlightColor) + } + attrs { + title = card.name + alt = "Card ${card.name}" + } + block() + } +} + +private fun CSSBuilder.highlightStyle(highlightColor: Color?) { + if (highlightColor != null) { + boxShadow( + offsetX = 0.px, + offsetY = 0.px, + blurRadius = 1.rem, + spreadRadius = 0.1.rem, + color = highlightColor + ) + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt new file mode 100644 index 00000000..d54a0240 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt @@ -0,0 +1,178 @@ +package org.luxons.sevenwonders.ui.components.game + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpButtonGroup +import com.palantir.blueprintjs.bpOverlay +import kotlinx.css.Position +import kotlinx.css.background +import kotlinx.css.backgroundSize +import kotlinx.css.bottom +import kotlinx.css.left +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.transform +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.css.right +import kotlinx.css.top +import kotlinx.html.DIV +import org.luxons.sevenwonders.model.Action +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.model.api.PlayerDTO +import org.luxons.sevenwonders.model.cards.HandCard +import org.luxons.sevenwonders.ui.components.GlobalStyles +import org.luxons.sevenwonders.ui.redux.GameState +import org.luxons.sevenwonders.ui.redux.RequestPrepareMove +import org.luxons.sevenwonders.ui.redux.RequestSayReady +import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove +import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch +import react.RBuilder +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.ReactElement +import react.dom.* +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv + +interface GameSceneStateProps: RProps { + var playerIsReady: Boolean + var players: List<PlayerDTO> + var gameState: GameState? + var preparedMove: PlayerMove? + var preparedCard: HandCard? +} + +interface GameSceneDispatchProps: RProps { + var sayReady: () -> Unit + var prepareMove: (move: PlayerMove) -> Unit + var unprepareMove: () -> Unit +} + +interface GameSceneProps : GameSceneStateProps, GameSceneDispatchProps + +private class GameScene(props: GameSceneProps) : RComponent<GameSceneProps, RState>(props) { + + override fun RBuilder.render() { + styledDiv { + css { + background = "url('images/background-papyrus3.jpg')" + backgroundSize = "cover" + +GlobalStyles.fullscreen + } + val turnInfo = props.gameState?.turnInfo + if (turnInfo == null) { + p { +"Error: no turn info data"} + } else { + turnInfoScene(turnInfo) + } + } + } + + private fun RBuilder.sayReadyButton(): ReactElement { + val isReady = props.playerIsReady + val intent = if (isReady) Intent.SUCCESS else Intent.PRIMARY + return styledDiv { + css { + position = Position.absolute + bottom = 6.rem + left = 50.pct + transform { translate(tx = (-50).pct) } + } + bpButtonGroup { + bpButton( + large = true, + disabled = isReady, + intent = intent, + icon = if (isReady) "tick-circle" else "play", + onClick = { props.sayReady() } + ) { + +"READY" + } + // not really a button, but nice for style + bpButton( + large = true, + icon = "people", + disabled = isReady, + intent = intent + ) { + +"${props.players.count { it.isReady }}/${props.players.size}" + } + } + } + } + + private fun RBuilder.turnInfoScene(turnInfo: PlayerTurnInfo) { + val board = turnInfo.table.boards[turnInfo.playerIndex] + div { + // TODO use blueprint's Callout component without header and primary intent + p { + turnInfo.message } + boardComponent(board = board) + val hand = turnInfo.hand + if (hand != null) { + handComponent( + cards = hand, + wonderUpgradable = turnInfo.wonderBuildability.isBuildable, + preparedMove = props.preparedMove, + prepareMove = props.prepareMove + ) + } + val card = props.preparedCard + if (card != null) { + preparedMove(card) + } + if (turnInfo.action == Action.SAY_READY) { + sayReadyButton() + } + productionBar(gold = board.gold, production = board.production) + } + } + + private fun RBuilder.preparedMove(card: HandCard) { + bpOverlay(isOpen = true, onClose = props.unprepareMove) { + styledDiv { + css { +GlobalStyles.fixedCenter } + cardImage(card) + styledDiv { + css { + position = Position.absolute + top = 0.px + right = 0.px + } + bpButton( + icon = "cross", + title = "Cancel prepared move", + small = true, + intent = Intent.DANGER, + onClick = { props.unprepareMove() } + ) + } + } + } + } +} + +fun RBuilder.gameScene() = gameScene {} + +private val gameScene: RClass<GameSceneProps> = connectStateAndDispatch<GameSceneStateProps, GameSceneDispatchProps, + GameSceneProps>( + clazz = GameScene::class, + mapDispatchToProps = { dispatch, _ -> + prepareMove = { move -> dispatch(RequestPrepareMove(move)) } + unprepareMove = { dispatch(RequestUnprepareMove()) } + sayReady = { dispatch(RequestSayReady()) } + }, + mapStateToProps = { state, _ -> + playerIsReady = state.currentPlayer?.isReady == true + players = state.gameState?.players ?: emptyList() + gameState = state.gameState + preparedMove = state.gameState?.currentPreparedMove + preparedCard = state.gameState?.currentPreparedCard + } +) + diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt new file mode 100644 index 00000000..17ceffd2 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt @@ -0,0 +1,174 @@ +package org.luxons.sevenwonders.ui.components.game + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpButtonGroup +import kotlinx.css.Align +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.Display +import kotlinx.css.GridColumn +import kotlinx.css.GridRow +import kotlinx.css.Position +import kotlinx.css.alignItems +import kotlinx.css.bottom +import kotlinx.css.display +import kotlinx.css.filter +import kotlinx.css.gridColumn +import kotlinx.css.gridRow +import kotlinx.css.height +import kotlinx.css.left +import kotlinx.css.margin +import kotlinx.css.maxHeight +import kotlinx.css.maxWidth +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.boxShadow +import kotlinx.css.properties.s +import kotlinx.css.properties.transform +import kotlinx.css.properties.transition +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.css.vh +import kotlinx.css.vw +import kotlinx.css.width +import kotlinx.css.zIndex +import kotlinx.html.DIV +import org.luxons.sevenwonders.model.MoveType +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.cards.HandCard +import org.luxons.sevenwonders.model.cards.PreparedCard +import org.luxons.sevenwonders.ui.components.game.cardImage +import react.RBuilder +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv + +fun RBuilder.handComponent( + cards: List<HandCard>, + wonderUpgradable: Boolean, + preparedMove: PlayerMove?, + prepareMove: (PlayerMove) -> Unit +) { + styledDiv { + css { + handStyle() + } + cards.filter { it.name != preparedMove?.cardName }.forEachIndexed { index, c -> + handCard( + card = c, + wonderUpgradable = wonderUpgradable, + prepareMove = prepareMove + ) { + attrs { + key = index.toString() + } + } + } + } +} + +private fun RBuilder.handCard( + card: HandCard, + wonderUpgradable: Boolean, + prepareMove: (PlayerMove) -> Unit, + block: StyledDOMBuilder<DIV>.() -> Unit +) { + styledDiv { + css { + handCardStyle() + } + block() + cardImage(card) { + css { + handCardImgStyle(card.playability.isPlayable) + } + } + actionButtons(card, wonderUpgradable, prepareMove) + } +} + +private fun RBuilder.actionButtons(card: HandCard, wonderUpgradable: Boolean, prepareMove: (PlayerMove) -> Unit) { + // class: action-buttons + styledDiv { + css { + alignItems = Align.flexEnd + display = Display.none + gridRow = GridRow("1") + gridColumn = GridColumn("1") + + ancestorHover(".hand-card") { + display = Display.flex + } + } + bpButtonGroup { + bpButton(title = "PLAY", + large = true, + intent = Intent.SUCCESS, + icon = "play", + disabled = !card.playability.isPlayable, + onClick = { prepareMove(PlayerMove(MoveType.PLAY, card.name)) }) + bpButton(title = "UPGRADE WONDER", + large = true, + intent = Intent.PRIMARY, + icon = "key-shift", + disabled = !wonderUpgradable, + onClick = { prepareMove(PlayerMove(MoveType.UPGRADE_WONDER, card.name)) }) + bpButton(title = "DISCARD", + large = true, + intent = Intent.DANGER, + icon = "cross", + onClick = { prepareMove(PlayerMove(MoveType.DISCARD, card.name)) }) + } + } +} + +private fun CSSBuilder.handStyle() { + alignItems = Align.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 = 55.pct) + } + transition(duration = 0.5.s) + zIndex = 30 + + hover { + bottom = 4.rem + transform { + translate(tx = (-50).pct, ty = 0.pct) + } + } +} + +private fun CSSBuilder.handCardStyle() { + classes.add("hand-card") + alignItems = Align.flexEnd + display = Display.grid + margin(all = 0.2.rem) +} + +private fun CSSBuilder.handCardImgStyle(isPlayable: Boolean) { + gridRow = GridRow("1") + gridColumn = GridColumn("1") + maxWidth = 13.vw + maxHeight = 60.vh + transition(duration = 0.1.s) + width = 11.rem + + ancestorHover(".hand-card") { + boxShadow(offsetX = 0.px, offsetY = 10.px, blurRadius = 40.px, color = Color.black) + width = 14.rem + maxWidth = 15.vw + maxHeight = 90.vh + } + + if (!isPlayable) { + filter = "grayscale(50%) contrast(50%)" + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt new file mode 100644 index 00000000..773e9835 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt @@ -0,0 +1,164 @@ +package org.luxons.sevenwonders.ui.components.game + +import kotlinx.css.Align +import kotlinx.css.BorderStyle +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.Display +import kotlinx.css.Position +import kotlinx.css.VerticalAlign +import kotlinx.css.alignItems +import kotlinx.css.background +import kotlinx.css.bottom +import kotlinx.css.color +import kotlinx.css.display +import kotlinx.css.fontFamily +import kotlinx.css.fontSize +import kotlinx.css.height +import kotlinx.css.margin +import kotlinx.css.marginLeft +import kotlinx.css.position +import kotlinx.css.properties.borderTop +import kotlinx.css.properties.boxShadow +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.css.verticalAlign +import kotlinx.css.vw +import kotlinx.css.width +import kotlinx.css.zIndex +import kotlinx.html.DIV +import kotlinx.html.title +import org.luxons.sevenwonders.model.boards.Production +import org.luxons.sevenwonders.model.resources.CountedResource +import org.luxons.sevenwonders.model.resources.ResourceType +import react.RBuilder +import react.dom.* +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv +import styled.styledImg +import styled.styledSpan + +fun RBuilder.productionBar(gold: Int, production: Production) { + styledDiv { + css { + productionBarStyle() + } + goldIndicator(gold) + fixedResources(production.fixedResources) + alternativeResources(production.alternativeResources) + } +} + +private fun RBuilder.goldIndicator(amount: Int) { + tokenWithCount(tokenName = "coin", count = amount) +} + +private fun RBuilder.fixedResources(resources: List<CountedResource>) { + styledDiv { + css { + margin = "auto" + display = Display.flex + } + resources.forEach { + tokenWithCount(tokenName = getTokenName(it.type), count = it.count) { + attrs { key = it.type.toString() } + css { marginLeft = 1.rem } + } + } + } +} + +private fun RBuilder.alternativeResources(resources: Set<Set<ResourceType>>) { + styledDiv { + css { + margin = "auto" + display = Display.flex + } + resources.forEachIndexed { index, res -> + resourceChoice(types = res) { + attrs { + key = index.toString() + } + } + } + } +} + +private fun RBuilder.resourceChoice(types: Set<ResourceType>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + css { + marginLeft = (1.5).rem + } + block() + for ((i, t) in types.withIndex()) { + tokenImage(tokenName = getTokenName(t)) { + attrs { this.key = t.toString() } + } + if (i < types.indices.last) { + styledSpan { css { choiceSeparatorStyle() } } + } + } + } +} + +private fun RBuilder.tokenWithCount(tokenName: String, count: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + block() + tokenImage(tokenName) + styledSpan { + css { tokenCountStyle() } + + "× $count" + } + } +} + +private fun RBuilder.tokenImage(tokenName: String, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledImg(src = getTokenImagePath(tokenName)) { + css { + tokenImageStyle() + } + attrs { + this.title = tokenName + this.alt = tokenName + } + } +} + +private fun getTokenImagePath(tokenName: String)= "/images/tokens/${tokenName}.png" + +private fun getTokenName(resourceType: ResourceType)= "resources/${resourceType.toString().toLowerCase()}" + +private fun CSSBuilder.productionBarStyle() { + alignItems = Align.center + background = "linear-gradient(#eaeaea, #888 7%)" + bottom = 0.px + borderTop(width = 1.px, color = Color("#8b8b8b"), style = BorderStyle.solid) + boxShadow(blurRadius = 15.px, color = Color("#747474")) + display = Display.flex + height = (3.5).rem + width = 100.vw + position = Position.fixed + zIndex = 99 +} + +private fun CSSBuilder.choiceSeparatorStyle() { + fontSize = 2.rem + verticalAlign = VerticalAlign.middle + margin(all = 5.px) + color = Color("#c29929") + declarations["text-shadow"] = "0 0 1px black" +} + +private fun CSSBuilder.tokenImageStyle() { + height = 3.rem + width = 3.rem + verticalAlign = VerticalAlign.middle +} + +private fun CSSBuilder.tokenCountStyle() { + fontFamily = "fantasy" + fontSize = 1.5.rem + verticalAlign = VerticalAlign.middle + marginLeft = 0.2.rem +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt new file mode 100644 index 00000000..876a167e --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt @@ -0,0 +1,76 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpInputGroup +import kotlinx.css.Display +import kotlinx.css.FlexDirection +import kotlinx.css.JustifyContent +import kotlinx.css.display +import kotlinx.css.flexDirection +import kotlinx.css.justifyContent +import kotlinx.html.js.onSubmitFunction +import org.luxons.sevenwonders.ui.redux.RequestCreateGame +import org.luxons.sevenwonders.ui.redux.connectDispatch +import org.luxons.sevenwonders.ui.utils.createElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event +import react.RBuilder +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.dom.* +import styled.css +import styled.styledDiv + +private interface CreateGameFormProps: RProps { + var createGame: (String) -> Unit +} + +private data class CreateGameFormState(var gameName: String = ""): RState + +private class CreateGameForm(props: CreateGameFormProps): RComponent<CreateGameFormProps, CreateGameFormState>(props) { + + override fun CreateGameFormState.init(props: CreateGameFormProps) { + gameName = "" + } + + override fun RBuilder.render() { + styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.row + justifyContent = JustifyContent.spaceBetween + } + form { + attrs { + onSubmitFunction = { e -> createGame(e) } + } + + bpInputGroup( + placeholder = "Game name", + onChange = { e -> + val input = e.currentTarget as HTMLInputElement + setState(transformState = { CreateGameFormState(input.value) }) + }, + rightElement = createGameButton() + ) + } + playerInfo() + } + } + + private fun createGameButton() = createElement { + bpButton(minimal = true, intent = Intent.PRIMARY, icon = "add", onClick = { e -> createGame(e) }) + } + + private fun createGame(e: Event) { + e.preventDefault() // prevents refreshing the page when pressing Enter + props.createGame(state.gameName) + } +} + +val createGameForm: RClass<RProps> = connectDispatch(CreateGameForm::class) { dispatch, _ -> + createGame = { name -> dispatch(RequestCreateGame(name)) } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt new file mode 100644 index 00000000..2f860ca7 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt @@ -0,0 +1,10 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import react.RBuilder +import react.dom.* + +fun RBuilder.gameBrowser() = div { + h1 { +"Games" } + createGameForm {} + gameList() +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt new file mode 100644 index 00000000..47c17da1 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt @@ -0,0 +1,133 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import com.palantir.blueprintjs.Classes +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpIcon +import com.palantir.blueprintjs.bpTag +import kotlinx.css.Align +import kotlinx.css.Display +import kotlinx.css.FlexDirection +import kotlinx.css.VerticalAlign +import kotlinx.css.alignItems +import kotlinx.css.display +import kotlinx.css.flexDirection +import kotlinx.css.verticalAlign +import kotlinx.html.classes +import kotlinx.html.title +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.api.State +import org.luxons.sevenwonders.ui.redux.RequestJoinGame +import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch +import react.RBuilder +import react.RComponent +import react.RProps +import react.RState +import react.dom.* +import styled.css +import styled.styledDiv +import styled.styledSpan +import styled.styledTr + +interface GameListStateProps : RProps { + var connectedPlayer: ConnectedPlayer + var games: List<LobbyDTO> +} + +interface GameListDispatchProps: RProps { + var joinGame: (Long) -> Unit +} + +interface GameListProps : GameListStateProps, GameListDispatchProps + +class GameListPresenter(props: GameListProps) : RComponent<GameListProps, RState>(props) { + + override fun RBuilder.render() { + table { + attrs { + classes = setOf(Classes.HTML_TABLE) + } + thead { + gameListHeaderRow() + } + tbody { + props.games.forEach { + gameListItemRow(it, props.joinGame) + } + } + } + } + + private fun RBuilder.gameListHeaderRow() = tr { + th { +"Name" } + th { +"Status" } + th { +"Nb Players" } + th { +"Join" } + } + + private fun RBuilder.gameListItemRow(lobby: LobbyDTO, joinGame: (Long) -> Unit) = styledTr { + css { + verticalAlign = VerticalAlign.middle + } + attrs { + key = lobby.id.toString() + } + td { +lobby.name } + td { gameStatus(lobby.state) } + td { playerCount(lobby.players.size) } + td { joinButton(lobby) } + } + + private fun RBuilder.gameStatus(state: State) { + val intent = when(state) { + State.LOBBY -> Intent.SUCCESS + State.PLAYING -> Intent.WARNING + State.FINISHED -> Intent.DANGER + } + bpTag(minimal = true, intent = intent) { + +state.toString() + } + } + + private fun RBuilder.playerCount(nPlayers: Int) { + styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.row + alignItems = Align.center + } + attrs { + title = "Number of players" + } + bpIcon(name = "people", title = null) + styledSpan { + +nPlayers.toString() + } + } + } + + private fun RBuilder.joinButton(lobby: LobbyDTO) { + val joinability = lobby.joinability(props.connectedPlayer.displayName) + bpButton( + minimal = true, + title = joinability.tooltip, + icon = "arrow-right", + disabled = !joinability.canDo, + onClick = { props.joinGame(lobby.id) } + ) + } +} + +fun RBuilder.gameList() = gameList {} + +private 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)) } + } +) diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt new file mode 100644 index 00000000..b939dfe1 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt @@ -0,0 +1,36 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.ui.redux.connectState +import react.RBuilder +import react.RComponent +import react.RProps +import react.RState +import react.dom.* + +interface PlayerInfoProps : RProps { + var connectedPlayer: ConnectedPlayer? +} + +class PlayerInfoPresenter(props: PlayerInfoProps) : RComponent<PlayerInfoProps, RState>(props) { + + override fun RBuilder.render() { + span { + b { + +"Username:" + } + props.connectedPlayer?.let { + + " ${it.displayName}" + } + } + } +} + +fun RBuilder.playerInfo() = playerInfo {} + +private val playerInfo = connectState( + clazz = PlayerInfoPresenter::class, + mapStateToProps = { state, _ -> + connectedPlayer = state.connectedPlayer + } +) diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt new file mode 100644 index 00000000..1aa4be43 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt @@ -0,0 +1,64 @@ +package org.luxons.sevenwonders.ui.components.home + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpInputGroup +import kotlinx.html.js.onSubmitFunction +import org.luxons.sevenwonders.ui.redux.RequestChooseName +import org.luxons.sevenwonders.ui.redux.connectDispatch +import org.luxons.sevenwonders.ui.utils.createElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event +import react.RBuilder +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.ReactElement +import react.dom.* + +private interface ChooseNameFormProps: RProps { + var chooseUsername: (String) -> Unit +} + +private data class ChooseNameFormState(var username: String = ""): RState + +private class ChooseNameForm(props: ChooseNameFormProps): RComponent<ChooseNameFormProps, ChooseNameFormState>(props) { + + override fun ChooseNameFormState.init(props: ChooseNameFormProps) { + username = "" + } + + override fun RBuilder.render() { + form { + attrs.onSubmitFunction = { e -> chooseUsername(e) } + bpInputGroup( + large = true, + placeholder = "Username", + rightElement = submitButton(), + onChange = { e -> + val input = e.currentTarget as HTMLInputElement + setState(transformState = { ChooseNameFormState(input.value) }) + } + ) + } + } + + private fun submitButton(): ReactElement = createElement { + bpButton( + minimal = true, + icon = "arrow-right", + intent = Intent.PRIMARY, + onClick = { e -> chooseUsername(e) } + ) + } + + private fun chooseUsername(e: Event) { + e.preventDefault() + props.chooseUsername(state.username) + } +} + +val chooseNameForm: RClass<RProps> = connectDispatch(ChooseNameForm::class) { dispatch, _ -> + chooseUsername = { name -> dispatch(RequestChooseName(name)) } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt new file mode 100644 index 00000000..43a1592b --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt @@ -0,0 +1,21 @@ +package org.luxons.sevenwonders.ui.components.home + +import org.luxons.sevenwonders.ui.components.GlobalStyles +import react.RBuilder +import react.dom.* +import styled.css +import styled.styledDiv + +private const val LOGO = "images/logo-7-wonders.png" + +fun RBuilder.home() = styledDiv { + css { + +GlobalStyles.fullscreen + +HomeStyles.centerChildren + +HomeStyles.zeusBackground + } + + img(src = LOGO, alt = "Seven Wonders") {} + + chooseNameForm {} +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt new file mode 100644 index 00000000..624f430c --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt @@ -0,0 +1,19 @@ +package org.luxons.sevenwonders.ui.components.home + +import kotlinx.css.* +import styled.StyleSheet + +object HomeStyles : StyleSheet("HomeStyles", isStatic = true) { + + val zeusBackground by css { + background = "url('images/background-zeus-temple.jpg') center no-repeat" + backgroundSize = "cover" + } + + val centerChildren by css { + display = Display.flex + flexDirection = FlexDirection.column + alignItems = Align.center + justifyContent = JustifyContent.center + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt new file mode 100644 index 00000000..5b13d8b1 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt @@ -0,0 +1,66 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.api.PlayerDTO +import org.luxons.sevenwonders.ui.redux.RequestStartGame +import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch +import react.RBuilder +import react.RComponent +import react.RProps +import react.RState +import react.dom.* + +interface LobbyStateProps : RProps { + var currentGame: LobbyDTO? + var currentPlayer: PlayerDTO? +} + +interface LobbyDispatchProps : RProps { + var startGame: () -> Unit +} + +interface LobbyProps : LobbyDispatchProps, LobbyStateProps + +class LobbyPresenter(props: LobbyProps) : RComponent<LobbyProps, RState>(props) { + + override fun RBuilder.render() { + val currentGame = props.currentGame + val currentPlayer = props.currentPlayer + if (currentGame == null || currentPlayer == null) { + div { +"Error: no current game." } + return + } + div { + h2 { +"${currentGame.name} — Lobby" } + radialPlayerList(currentGame.players, currentPlayer) + if (currentPlayer.isGameOwner) { + val startability = currentGame.startability(currentPlayer.username) + bpButton( + large = true, + intent = Intent.PRIMARY, + icon = "play", + title = startability.tooltip, + disabled = !startability.canDo, + onClick = { props.startGame() } + ) { + + "START" + } + } + } + } +} + +fun RBuilder.lobby() = lobby {} + +private val lobby = connectStateAndDispatch<LobbyStateProps, LobbyDispatchProps, LobbyProps>( + clazz = LobbyPresenter::class, + mapStateToProps = { state, _ -> + currentGame = state.currentLobby + currentPlayer = state.currentPlayer + }, + mapDispatchToProps = { dispatch, _ -> + startGame = { dispatch(RequestStartGame()) } + } +) diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt new file mode 100644 index 00000000..be3bb1de --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt @@ -0,0 +1,121 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import kotlinx.css.CSSBuilder +import kotlinx.css.Display +import kotlinx.css.ListStyleType +import kotlinx.css.Position +import kotlinx.css.display +import kotlinx.css.height +import kotlinx.css.left +import kotlinx.css.listStyleType +import kotlinx.css.margin +import kotlinx.css.padding +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.Timing +import kotlinx.css.properties.ms +import kotlinx.css.properties.transform +import kotlinx.css.properties.transition +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.top +import kotlinx.css.width +import kotlinx.css.zIndex +import react.RBuilder +import react.ReactElement +import react.dom.* +import styled.css +import styled.styledDiv +import styled.styledLi +import styled.styledUl + +typealias ElementBuilder = RBuilder.() -> ReactElement + +fun RBuilder.radialList( + itemBuilders: List<ElementBuilder>, + centerElementBuilder: ElementBuilder, + itemWidth: Int, + itemHeight: Int, + options: RadialConfig = RadialConfig() +): ReactElement { + val containerWidth = options.diameter + itemWidth + val containerHeight = options.diameter + itemHeight + + return styledDiv { + css { + zeroMargins() + position = Position.relative + width = containerWidth.px + height = containerHeight.px + } + radialListItems(itemBuilders, options) + radialListCenter(centerElementBuilder) + } +} + +private fun RBuilder.radialListItems(itemBuilders: List<ElementBuilder>, radialConfig: RadialConfig): ReactElement { + val offsets = offsetsFromCenter(itemBuilders.size, radialConfig) + return styledUl { + css { + zeroMargins() + transition(property = "all", duration = 500.ms, timing = Timing.easeInOut) + zIndex = 1 + width = radialConfig.diameter.px + height = radialConfig.diameter.px + absoluteCenter() + } + itemBuilders.forEachIndexed { i, itemBuilder -> + radialListItem(itemBuilder, i, offsets[i]) + } + } +} + +private fun RBuilder.radialListItem(itemBuilder: ElementBuilder, i: Int, offset: CartesianCoords): ReactElement { + return styledLi { + css { + display = Display.block + position = Position.absolute + top = 50.pct + left = 50.pct + zeroMargins() + listStyleType = ListStyleType.unset + transition("all", 500.ms, Timing.easeInOut) + zIndex = 1 + transform { + translate(offset.x.px, offset.y.px) + translate((-50).pct, (-50).pct) + } + } + attrs { + key = "$i" + } + itemBuilder() + } +} + +private fun RBuilder.radialListCenter(centerElement: ElementBuilder?): ReactElement? { + if (centerElement == null) { + return null + } + return styledDiv { + css { + zIndex = 0 + absoluteCenter() + } + centerElement() + } +} + +private fun CSSBuilder.absoluteCenter() { + position = Position.absolute + left = 50.pct + top = 50.pct + transform { + translate((-50).pct, (-50).pct) + } +} + +private fun CSSBuilder.zeroMargins() { + margin = "0" + padding = "0" +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt new file mode 100644 index 00000000..d668ab9b --- /dev/null +++ b/sw-ui/src/main/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/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt new file mode 100644 index 00000000..ff541696 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt @@ -0,0 +1,106 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import com.palantir.blueprintjs.IconName +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpIcon +import kotlinx.css.Align +import kotlinx.css.Display +import kotlinx.css.FlexDirection +import kotlinx.css.alignItems +import kotlinx.css.display +import kotlinx.css.flexDirection +import kotlinx.css.margin +import kotlinx.css.opacity +import org.luxons.sevenwonders.model.api.PlayerDTO +import react.RBuilder +import react.ReactElement +import react.dom.* +import styled.css +import styled.styledDiv +import styled.styledH5 + +fun RBuilder.radialPlayerList(players: List<PlayerDTO>, currentPlayer: PlayerDTO): ReactElement { + val playerItemBuilders = players + .growTo(targetSize = 3) + .withUserFirst(currentPlayer) + .map { p -> p.elementBuilder(p?.username == currentPlayer.username) } + + val tableImgBuilder: ElementBuilder = { roundTableImg() } + + return radialList( + itemBuilders = playerItemBuilders, + centerElementBuilder = tableImgBuilder, + itemWidth = 120, + itemHeight = 100, + options = RadialConfig( + radius = 175, + firstItemAngleDegrees = 180 // self at the bottom + ) + ) +} + +private fun RBuilder.roundTableImg(): ReactElement = img { + attrs { + src = "images/round-table.png" + alt = "Round table" + width = "200" + height = "200" + } +} + +private fun List<PlayerDTO?>.withUserFirst(me: PlayerDTO): List<PlayerDTO?> { + val nonUsersBeginning = takeWhile { it?.username != me.username } + val userToEnd = subList(nonUsersBeginning.size, size) + return userToEnd + nonUsersBeginning +} + +private fun <T> List<T>.growTo(targetSize: Int): List<T?> { + if (size >= targetSize) return this + return this + List(targetSize - size) { null } +} + +private fun PlayerDTO?.elementBuilder(isMe: Boolean): ElementBuilder { + if (this == null) { + return { playerPlaceholder() } + } else { + return { playerItem(this@elementBuilder, isMe) } + } +} + +private fun RBuilder.playerItem(player: PlayerDTO, isMe: Boolean): ReactElement = styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.column + alignItems = Align.center + } + val title = if (player.isGameOwner) "Game owner" else null + userIcon(isMe = isMe, isOwner = player.isGameOwner, title = title) + styledH5 { + css { + margin = "0" + } + +player.displayName + } +} + +private fun RBuilder.playerPlaceholder(): ReactElement = styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.column + alignItems = Align.center + opacity = 0.3 + } + userIcon(isMe = false, isOwner = false, title = "Waiting for player...") + styledH5 { + css { + margin = "0" + } + +"?" + } +} + +private fun RBuilder.userIcon(isMe: Boolean, isOwner: Boolean, title: String?): ReactElement { + val iconName: IconName = if (isOwner) "badge" else "user" + val intent: Intent = if (isMe) Intent.WARNING else Intent.NONE + return bpIcon(name = iconName, intent = intent, size = 50, title = title) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt new file mode 100644 index 00000000..3e3de561 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt @@ -0,0 +1,26 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.cards.PreparedCard +import redux.RAction + +data class SetCurrentPlayerAction(val player: ConnectedPlayer): RAction + +data class UpdateGameListAction(val games: List<LobbyDTO>): RAction + +data class UpdateLobbyAction(val lobby: LobbyDTO): RAction + +data class EnterLobbyAction(val lobby: LobbyDTO): RAction + +data class EnterGameAction(val lobby: LobbyDTO, val turnInfo: PlayerTurnInfo): 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/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt new file mode 100644 index 00000000..836f5b4e --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt @@ -0,0 +1,23 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.model.CustomizableSettings +import org.luxons.sevenwonders.model.PlayerMove +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 RequestReorderPlayers(val orderedPlayers: List<String>): RAction + +data class RequestUpdateSettings(val settings: CustomizableSettings): RAction + +class RequestStartGame: RAction + +class RequestSayReady: RAction + +data class RequestPrepareMove(val move: PlayerMove): RAction + +class RequestUnprepareMove: RAction diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt new file mode 100644 index 00000000..c21f6deb --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt @@ -0,0 +1,84 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.PlayerTurnInfo +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.State +import org.luxons.sevenwonders.model.cards.CardBack +import org.luxons.sevenwonders.model.cards.HandCard +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 currentPlayer: PlayerDTO? = (gameState?.players ?: currentLobby?.players)?.first { + it.username == connectedPlayer?.username + } + val games: List<LobbyDTO> = gamesById.values.toList() +} + +data class GameState( + val id: Long, + val players: List<PlayerDTO>, + val turnInfo: PlayerTurnInfo?, + val preparedCardsByUsername: Map<String, CardBack?> = emptyMap(), + val currentPreparedMove: PlayerMove? = null +) { + val currentPreparedCard: HandCard? + get() = turnInfo?.hand?.firstOrNull { it.name == currentPreparedMove?.cardName } +} + +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) +) + +private fun gamesReducer(games: Map<Long, LobbyDTO>, action: RAction): Map<Long, LobbyDTO> = when (action) { + is UpdateGameListAction -> (games + action.games.associateBy { it.id }).filterValues { it.state != State.FINISHED } + 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 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( + id = action.lobby.id, + players = action.lobby.players, + turnInfo = action.turnInfo + ) + is PreparedMoveEvent -> gameState?.copy(currentPreparedMove = action.move) + is RequestUnprepareMove -> gameState?.copy(currentPreparedMove = null) + is PreparedCardEvent -> gameState?.copy( + preparedCardsByUsername = gameState.preparedCardsByUsername + (action.card.player.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) }, + turnInfo = action.turnInfo + ) + else -> gameState +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt new file mode 100644 index 00000000..6f50a627 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt @@ -0,0 +1,29 @@ +package org.luxons.sevenwonders.ui.redux + +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 +import kotlin.browser.window + +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/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt new file mode 100644 index 00000000..67ac5304 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt @@ -0,0 +1,39 @@ +package org.luxons.sevenwonders.ui.redux + +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.invoke +import react.redux.rConnect +import redux.RAction +import redux.WrapperAction +import kotlin.reflect.KClass + +inline fun <reified DP : RProps> connectDispatch( + clazz: KClass<out RComponent<DP, out RState>>, + noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit +): RClass<RProps> { + val connect = rConnect(mapDispatchToProps = mapDispatchToProps) + return connect.invoke(clazz.js.unsafeCast<RClass<DP>>()) +} + +inline fun <reified SP : RProps> connectState( + clazz: KClass<out RComponent<SP, out RState>>, + noinline mapStateToProps: SP.(SwState, RProps) -> Unit +): RClass<RProps> { + val connect = rConnect(mapStateToProps = mapStateToProps) + return connect.invoke(clazz.js.unsafeCast<RClass<SP>>()) +} + +inline fun <reified SP : RProps, reified DP : RProps, reified P : RProps> connectStateAndDispatch( + clazz: KClass<out RComponent<P, out RState>>, + noinline mapStateToProps: SP.(SwState, RProps) -> Unit, + noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit +): RClass<RProps> { + val connect = rConnect<SwState, RAction, WrapperAction, RProps, SP, DP, P>( + mapStateToProps = mapStateToProps, + mapDispatchToProps = mapDispatchToProps + ) + return connect.invoke(clazz.js.unsafeCast<RClass<P>>()) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt new file mode 100644 index 00000000..7806bc98 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt @@ -0,0 +1,50 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.hildan.krossbow.stomp.StompSubscription +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.ui.redux.EnterLobbyAction +import org.luxons.sevenwonders.ui.redux.RequestCreateGame +import org.luxons.sevenwonders.ui.redux.RequestJoinGame +import org.luxons.sevenwonders.ui.redux.UpdateGameListAction +import org.luxons.sevenwonders.ui.router.Navigate +import org.luxons.sevenwonders.ui.router.Route +import org.luxons.sevenwonders.ui.utils.awaitFirst + +suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) { + GameBrowserSaga(session, this).run() +} + +private class GameBrowserSaga( + private val session: SevenWondersSession, + private val sagaContext: SwSagaContext +) { + suspend fun run() { + coroutineScope { + val gamesSubscription = session.watchGames() + launch { dispatchGameUpdates(gamesSubscription) } + val lobby = awaitCreateOrJoinGame() + gamesSubscription.unsubscribe() + sagaContext.dispatch(EnterLobbyAction(lobby)) + sagaContext.dispatch(Navigate(Route.LOBBY)) + } + } + + private suspend fun dispatchGameUpdates(gamesSubscription: StompSubscription<List<LobbyDTO>>) { + sagaContext.dispatchAll(gamesSubscription.messages) { UpdateGameListAction(it) } + } + + private suspend fun awaitCreateOrJoinGame(): LobbyDTO = awaitFirst(this::awaitCreateGame, this::awaitJoinGame) + + private suspend fun awaitCreateGame(): LobbyDTO { + val action = sagaContext.next<RequestCreateGame>() + return session.createGame(action.gameName) + } + + private suspend fun awaitJoinGame(): LobbyDTO { + val action = sagaContext.next<RequestJoinGame>() + return session.joinGame(action.gameId) + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt new file mode 100644 index 00000000..a9c2ca2c --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt @@ -0,0 +1,36 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.ui.redux.PlayerReadyEvent +import org.luxons.sevenwonders.ui.redux.PreparedCardEvent +import org.luxons.sevenwonders.ui.redux.PreparedMoveEvent +import org.luxons.sevenwonders.ui.redux.RequestPrepareMove +import org.luxons.sevenwonders.ui.redux.RequestSayReady +import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove +import org.luxons.sevenwonders.ui.redux.TurnInfoEvent + +suspend fun SwSagaContext.gameSaga(session: SevenWondersSession) { + val game = getState().gameState ?: error("Game saga run without a current game") + coroutineScope { + val playerReadySub = session.watchPlayerReady(game.id) + val preparedCardsSub = session.watchPreparedCards(game.id) + val turnInfoSub = session.watchTurns() + val sayReadyJob = launch { onEach<RequestSayReady> { session.sayReady() } } + val unprepareJob = launch { onEach<RequestUnprepareMove> { session.unprepareMove() } } + val prepareMoveJob = launch { + onEach<RequestPrepareMove> { + val move = session.prepareMove(it.move) + dispatch(PreparedMoveEvent(move)) + } + } + launch { dispatchAll(playerReadySub.messages) { PlayerReadyEvent(it) } } + launch { dispatchAll(preparedCardsSub.messages) { PreparedCardEvent(it) } } + launch { dispatchAll(turnInfoSub.messages) { TurnInfoEvent(it) } } + // TODO await game end + // TODO unsubscribe all subs, cancel all jobs + } + console.log("End of game saga") +} + diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt new file mode 100644 index 00000000..678276dc --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt @@ -0,0 +1,42 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.hildan.krossbow.stomp.StompSubscription +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.ui.redux.EnterGameAction +import org.luxons.sevenwonders.ui.redux.RequestStartGame +import org.luxons.sevenwonders.ui.redux.UpdateLobbyAction +import org.luxons.sevenwonders.ui.router.Navigate +import org.luxons.sevenwonders.ui.router.Route + +suspend fun SwSagaContext.lobbySaga(session: SevenWondersSession) { + val lobby = getState().currentLobby ?: error("Lobby saga run without a current lobby") + coroutineScope { + val lobbyUpdatesSubscription = session.watchLobbyUpdates() + launch { watchLobbyUpdates(lobbyUpdatesSubscription) } + val startGameJob = launch { awaitStartGame(session) } + + awaitGameStart(session, lobby.id) + + lobbyUpdatesSubscription.unsubscribe() + startGameJob.cancel() + dispatch(Navigate(Route.GAME)) + } +} + +private suspend fun SwSagaContext.watchLobbyUpdates(lobbyUpdatesSubscription: StompSubscription<LobbyDTO>) { + dispatchAll(lobbyUpdatesSubscription.messages) { UpdateLobbyAction(it) } +} + +private suspend fun SwSagaContext.awaitGameStart(session: SevenWondersSession, lobbyId: Long) { + val turnInfo = session.awaitGameStart(lobbyId) + val lobby = getState().currentLobby!! + dispatch(EnterGameAction(lobby, turnInfo)) +} + +private suspend fun SwSagaContext.awaitStartGame(session: SevenWondersSession) { + next<RequestStartGame>() + session.startGame() +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt new file mode 100644 index 00000000..c4a92581 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt @@ -0,0 +1,54 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.luxons.sevenwonders.client.SevenWondersClient +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.ui.redux.RequestChooseName +import org.luxons.sevenwonders.ui.redux.SetCurrentPlayerAction +import org.luxons.sevenwonders.ui.redux.SwState +import org.luxons.sevenwonders.ui.router.Route +import org.luxons.sevenwonders.ui.router.routerSaga +import redux.RAction +import redux.WrapperAction + +typealias SwSagaContext = SagaContext<SwState, RAction, WrapperAction> + +suspend fun SwSagaContext.rootSaga() = coroutineScope { + val action = next<RequestChooseName>() + val session = SevenWondersClient().connect("localhost:8000") + console.info("Connected to Seven Wonders web socket API") + + launch { + serverErrorSaga(session) + } + yield() // ensures the error saga starts + + val player = session.chooseName(action.playerName) + dispatch(SetCurrentPlayerAction(player)) + + routerSaga(Route.GAME_BROWSER) { + when (it) { + Route.HOME -> homeSaga(session) + Route.LOBBY -> lobbySaga(session) + Route.GAME_BROWSER -> gameBrowserSaga(session) + Route.GAME -> gameSaga(session) + } + } +} + +private suspend fun serverErrorSaga(session: SevenWondersSession) { + val errorsSub = session.watchErrors() + for (err in errorsSub.messages) { + // TODO use blueprintjs toaster + console.error("${err.code}: ${err.message}") + console.error(JSON.stringify(err)) + } +} + +private suspend fun SwSagaContext.homeSaga(session: SevenWondersSession) { + val action = next<RequestChooseName>() + val player = session.chooseName(action.playerName) + dispatch(SetCurrentPlayerAction(player)) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt new file mode 100644 index 00000000..1a57708e --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt @@ -0,0 +1,137 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import redux.Middleware +import redux.MiddlewareApi +import redux.RAction + +@OptIn(ExperimentalCoroutinesApi::class) +class SagaManager<S, A : RAction, R>( + private val monitor: ((A) -> Unit)? = null +) { + private lateinit var context: SagaContext<S, A, R> + + private val actions = BroadcastChannel<A>(16) + + 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) { + GlobalScope.launch { actions.send(action) } + } + + 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" + } + } +} + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class SagaContext<S, A : RAction, R>( + private val reduxApi: MiddlewareApi<S, A, R>, + private val actions: BroadcastChannel<A> +) { + /** + * Gets the current redux state. + */ + fun getState(): S = reduxApi.getState() + + /** + * Dispatches the given redux [action]. + */ + fun dispatch(action: A) { + reduxApi.dispatch(action) + } + + /** + * Dispatches an action given by [createAction] for each message received in [channel]. + */ + suspend fun <T> dispatchAll(channel: ReceiveChannel<T>, createAction: (T) -> A) { + for (msg in channel) { + reduxApi.dispatch(createAction(msg)) + } + } + + /** + * Executes [handle] on every action dispatched. This runs forever until the current coroutine is cancelled. + */ + suspend fun onEach(handle: suspend SagaContext<S, A, R>.(A) -> Unit) { + val channel = actions.openSubscription() + try { + for (a in channel) { + handle(a) + } + } finally { + channel.cancel() + } + } + + /** + * 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 + ) = onEach { + if (it is T) { + handle(it) + } + } + + /** + * Suspends until the next action matching the given [predicate] is dispatched, and returns that action. + */ + suspend fun next(predicate: (A) -> Boolean): A { + val channel = actions.openSubscription() + try { + for (a in channel) { + if (predicate(a)) { + return a + } + } + } finally { + channel.cancel() + } + error("Actions channel closed before receiving a matching action") + } + + /** + * Suspends until the next action of type [T] is dispatched, and returns that action. + */ + suspend inline fun <reified T : A> next(): T = next { it is T } as T +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt new file mode 100644 index 00000000..19e8bd94 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt @@ -0,0 +1,32 @@ +package org.luxons.sevenwonders.ui.router + +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.luxons.sevenwonders.ui.redux.sagas.SwSagaContext +import redux.RAction +import kotlin.browser.window + +enum class Route(val path: String) { + HOME("/"), + GAME_BROWSER("/games"), + LOBBY("/lobby"), + GAME("/game"), +} + +data class Navigate(val route: Route): RAction + +suspend fun SwSagaContext.routerSaga( + startRoute: Route, + runRouteSaga: suspend SwSagaContext.(Route) -> Unit +) { + coroutineScope { + window.location.hash = startRoute.path + var currentSaga: Job = launch { runRouteSaga(startRoute) } + onEach<Navigate> { + currentSaga.cancel() + window.location.hash = it.route.path + currentSaga = launch { runRouteSaga(it.route) } + } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt new file mode 100644 index 00000000..600f08d3 --- /dev/null +++ b/sw-ui/src/main/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/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt new file mode 100644 index 00000000..07b3f2b5 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt @@ -0,0 +1,16 @@ +package org.luxons.sevenwonders.ui.utils + +import kotlinx.html.SPAN +import kotlinx.html.attributesMapOf +import react.RBuilder +import react.ReactElement +import react.dom.* + +/** + * Creates a ReactElement without appending it (so that is can be passed around). + */ +fun createElement(block: RBuilder.() -> ReactElement): ReactElement { + return RDOMBuilder { SPAN(attributesMapOf("class", null), it) } + .apply { block() } + .create() +} |