diff options
author | Joffrey Bion <joffrey.bion@gmail.com> | 2023-07-06 00:16:45 +0200 |
---|---|---|
committer | Joffrey Bion <joffrey.bion@gmail.com> | 2023-07-06 00:30:55 +0200 |
commit | cdaae5279e3f53c146df2500a8d7a1d4eae2f674 (patch) | |
tree | 55347f2839caaf4688f4f989e9568ffd7102db63 /sw-ui/src/jsMain/kotlin/org/luxons | |
parent | Upgrade Kotlin JS wrappers to 1.0.0-pre.585 (diff) | |
download | seven-wonders-cdaae5279e3f53c146df2500a8d7a1d4eae2f674.tar.gz seven-wonders-cdaae5279e3f53c146df2500a8d7a1d4eae2f674.tar.bz2 seven-wonders-cdaae5279e3f53c146df2500a8d7a1d4eae2f674.zip |
Convert sw-ui module to Kotlin Multiplatform gradle pluginmain
Diffstat (limited to 'sw-ui/src/jsMain/kotlin/org/luxons')
41 files changed, 4614 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) +} |