diff options
Diffstat (limited to 'sw-ui/src/main/kotlin')
34 files changed, 2744 insertions, 0 deletions
diff --git a/sw-ui/src/main/kotlin/blueprintjs.kt b/sw-ui/src/main/kotlin/blueprintjs.kt new file mode 100644 index 00000000..13de8744 --- /dev/null +++ b/sw-ui/src/main/kotlin/blueprintjs.kt @@ -0,0 +1,525 @@ +@file:JsModule("@blueprintjs/core") +package com.palantir.blueprintjs + +import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event +import org.w3c.dom.events.MouseEvent +import react.* + +/** + * The four basic intents. + */ +// export declare const Intent: { +// NONE: "none"; +// PRIMARY: "primary"; +// SUCCESS: "success"; +// WARNING: "warning"; +// DANGER: "danger"; +//}; +//export declare type Intent = typeof Intent[keyof typeof Intent]; +external enum class Intent { + NONE, + PRIMARY, + SUCCESS, + WARNING, + DANGER +} + +/** Alignment along the horizontal axis. */ +//export declare const Alignment: { +// CENTER: "center"; +// LEFT: "left"; +// RIGHT: "right"; +//}; +//export declare type Alignment = typeof Alignment[keyof typeof Alignment]; +external enum class Alignment { + CENTER, + LEFT, + RIGHT +} + +/** + * A shared base interface for all Blueprint component props. + */ +external interface IProps : RProps { + /** A space-delimited list of class names to pass along to a child element. */ + var className: String? +} +external interface IIntentProps { + /** Visual intent color to apply to element. */ + var intent: Intent? +} +/** + * Interface for a clickable action, such as a button or menu item. + * These props can be spready directly to a `<Button>` or `<MenuItem>` element. + */ +external interface IActionProps : IIntentProps, IProps { + /** Whether this action is non-interactive. */ + var disabled: Boolean? + /** Name of a Blueprint UI icon (or an icon element) to render before the text. */ + var icon: IconName? + /** Click event handler. */ + var onClick: ((event: MouseEvent) -> Unit)? + /** Action text. Can be any single React renderable. */ + var text: String? +} +/** Interface for a link, with support for customizing target window. */ +external interface ILinkProps { + /** Link URL. */ + var href: String? + /** Link target attribute. Use `"_blank"` to open in a new window. */ + var target: String? +} +/** Interface for a controlled input. */ +external interface IControlledProps { + /** Initial value of the input, for uncontrolled usage. */ + var defaultValue: String? + /** Change event handler. Use `event.target.value` for new value. */ + var onChange: ((Event) -> Unit)? + /** Form value of the input, for controlled usage. */ + var value: String? +} +/** + * An interface for an option in a list, such as in a `<select>` or `RadioGroup`. + * These props can be spread directly to an `<option>` or `<Radio>` element. + */ +external interface IOptionProps : IProps { + /** Whether this option is non-interactive. */ + var disabled: Boolean? + /** Label text for this option. If omitted, `value` is used as the label. */ + var label: String? + /** Value of this option. */ + var value: Any? // String | Number +} + +external interface IIconProps : IIntentProps, IProps { + /** + * Color of icon. This is used as the `fill` attribute on the `<svg>` image + * so it will override any CSS `color` property, including that set by + * `intent`. If this prop is omitted, icon color is inherited from + * surrounding text. + */ + var color: String? + /** + * String for the `title` attribute on the rendered element, which will appear + * on hover as a native browser tooltip. + */ + var htmlTitle: String? + /** + * Name of a Blueprint UI icon, or an icon element, to render. This prop is + * required because it determines the content of the component, but it can + * be explicitly set to falsy values to render nothing. + * + * - If `null` or `undefined` or `false`, this component will render + * nothing. + * - If given an `IconName` (a string literal union of all icon names), that + * icon will be rendered as an `<svg>` with `<path>` tags. Unknown strings + * will render a blank icon to occupy space. + * - If given a `JSX.Element`, that element will be rendered and _all other + * props on this component are ignored._ This type is supported to + * simplify icon support in other Blueprint components. As a consumer, you + * should avoid using `<Icon icon={<Element />}` directly; simply render + * `<Element />` instead. + */ + var icon: IconName + /** + * Size of the icon, in pixels. Blueprint contains 16px and 20px SVG icon + * images, and chooses the appropriate resolution based on this prop. + * @default Icon.SIZE_STANDARD = 16 + */ + var iconSize: Int? + /** CSS style properties. */ + //var style: CSSProperties? // TODO + /** + * HTML tag to use for the rendered element. + * @default "span" + */ + var tagName: String? // keyof JSX.IntrinsicElements + /** + * Description string. This string does not appear in normal browsers, but + * it increases accessibility. For instance, screen readers will use it for + * aural feedback. By default, this is set to the icon's name. Pass an + * explicit falsy value to disable. + */ + var title: String? +} + +external class Icon : PureComponent<IIconProps, RState> { + + override fun render(): ReactElement? + + companion object { + val SIZE_STANDARD: Int = definedExternally + val SIZE_LARGE: Int = definedExternally + } +} + +external interface IButtonProps : IActionProps { + // artificially added to allow title on button (should probably be on more general props) + var title: String? + /** + * If set to `true`, the button will display in an active state. + * This is equivalent to setting `className={Classes.ACTIVE}`. + * @default false + */ + var active: Boolean? + /** + * Text alignment within button. By default, icons and text will be centered + * within the button. Passing `"left"` or `"right"` will align the button + * text to that side and push `icon` and `rightIcon` to either edge. Passing + * `"center"` will center the text and icons together. + * @default Alignment.CENTER + */ + var alignText: Alignment? + /** A ref handler that receives the native HTML element backing this component. */ + var elementRef: ((ref: HTMLElement?) -> Any)? + /** Whether this button should expand to fill its container. */ + var fill: Boolean? + /** Whether this button should use large styles. */ + var large: Boolean? + /** + * If set to `true`, the button will display a centered loading spinner instead of its contents. + * The width of the button is not affected by the value of this prop. + * @default false + */ + var loading: Boolean? + /** Whether this button should use minimal styles. */ + var minimal: Boolean? + /** Whether this button should use outlined styles. */ + var outlined: Boolean? + /** Name of a Blueprint UI icon (or an icon element) to render after the text. */ + var rightIcon: IconName? + /** Whether this button should use small styles. */ + var small: Boolean? + /** + * HTML `type` attribute of button. Accepted values are `"button"`, `"submit"`, and `"reset"`. + * Note that this prop has no effect on `AnchorButton`; it only affects `Button`. + * @default "button" + */ + var type: String? //"submit" | "reset" | "button"; +} + +external interface IButtonState : RState { + var isActive: Boolean +} + +abstract external class AbstractButton : PureComponent<IButtonProps, IButtonState> { +} + +external class Button : AbstractButton { + override fun render(): ReactElement +} +external class AnchorButton : AbstractButton { + override fun render(): ReactElement +} + +external interface IButtonGroupProps : IProps { + /** + * Text alignment within button. By default, icons and text will be centered + * within the button. Passing `"left"` or `"right"` will align the button + * text to that side and push `icon` and `rightIcon` to either edge. Passing + * `"center"` will center the text and icons together. + */ + var alignText: Alignment? + + /** + * Whether the button group should take up the full width of its container. + * @default false + */ + var fill: Boolean? + + /** + * Whether the child buttons should appear with minimal styling. + * @default false + */ + var minimal: Boolean? + + /** + * Whether the child buttons should appear with large styling. + * @default false + */ + var large: Boolean? + + /** + * Whether the button group should appear with vertical styling. + * @default false + */ + var vertical: Boolean? +} + +external class ButtonGroup : PureComponent<IButtonGroupProps, RState> { + override fun render(): ReactElement? +} + +external interface IInputGroupProps : IControlledProps, IIntentProps, IProps { + /** + * Whether the input is non-interactive. + * Note that `rightElement` must be disabled separately; this prop will not affect it. + * @default false + */ + var disabled: Boolean? + /** + * Whether the component should take up the full width of its container. + */ + var fill: Boolean? + /** Ref handler that receives HTML `<input>` element backing this component. */ + var inputRef: ((ref: HTMLInputElement?) -> Any)?; + /** + * Name of a Blueprint UI icon (or an icon element) to render on the left side of the input group, + * before the user's cursor. + */ + var leftIcon: IconName? + /** Whether this input should use large styles. */ + var large: Boolean? + /** Whether this input should use small styles. */ + var small: Boolean? + /** Placeholder text in the absence of any value. */ + var placeholder: String? + /** + * Element to render on right side of input. + * For best results, use a minimal button, tag, or small spinner. + */ + var rightElement: ReactElement? + /** Whether the input (and any buttons) should appear with rounded caps. */ + var round: Boolean? + /** + * HTML `input` type attribute. + * @default "text" + */ + var type: String? +} + +external interface IInputGroupState : RState { + var rightElementWidth: Int +} + +external class InputGroup : PureComponent<IInputGroupProps, IInputGroupState> { + override fun render(): ReactElement +} + +external interface ITagProps : IProps, IIntentProps { + /** + * Whether the tag should appear in an active state. + * @default false + */ + var active: Boolean? + /** + * Whether the tag should take up the full width of its container. + * @default false + */ + var fill: Boolean? + /** Name of a Blueprint UI icon (or an icon element) to render before the children. */ + var icon: IconName? + /** + * Whether the tag should visually respond to user interactions. If set + * to `true`, hovering over the tag will change its color and mouse cursor. + * + * Recommended when `onClick` is also defined. + * + * @default false + */ + var interactive: Boolean? + /** + * Whether this tag should use large styles. + * @default false + */ + var large: Boolean? + /** + * Whether this tag should use minimal styles. + * @default false + */ + var minimal: Boolean? + /** + * Whether tag content should be allowed to occupy multiple lines. + * If false, a single line of text will be truncated with an ellipsis if + * it overflows. Note that icons will be vertically centered relative to + * multiline text. + * @default false + */ + var multiline: Boolean? + /** + * Callback invoked when the tag is clicked. + * Recommended when `interactive` is `true`. + */ + var onClick: ((e: MouseEvent) -> Unit)?; + /** + * Click handler for remove button. + * The remove button will only be rendered if this prop is defined. + */ + var onRemove: ((e: MouseEvent, tagProps: ITagProps) -> Unit)? + /** Name of a Blueprint UI icon (or an icon element) to render after the children. */ + var rightIcon: IconName? + /** + * Whether this tag should have rounded ends. + * @default false + */ + var round: Boolean? +} + +external class Tag : PureComponent<ITagProps, RState> { + override fun render(): ReactElement +} + +external interface INonIdealStateProps : IProps { + /** An action to resolve the non-ideal state which appears after `description`. */ + var action: ReactElement? + + /** + * Advanced usage: React `children` will appear last (after `action`). + * Avoid passing raw strings as they will not receive margins and disrupt the layout flow. + */ + var children: ReactElement? + + /** + * A longer description of the non-ideal state. + * A string or number value will be wrapped in a `<div>` to preserve margins. + */ + var description: ReactElement? + + /** The name of a Blueprint icon or a JSX Element (such as `<Spinner/>`) to render above the title. */ + var icon: IconName? + + /** The title of the non-ideal state. */ + var title: ReactElement? +} + +external class NonIdealState : PureComponent<INonIdealStateProps, RState> { + override fun render(): ReactElement? +} + +external class Classes { + companion object { + val HTML_TABLE: String = definedExternally + } +} + +external interface IOverlayableProps : IOverlayLifecycleProps { + /** + * Whether the overlay should acquire application focus when it first opens. + * @default true + */ + var autoFocus: Boolean? + /** + * Whether pressing the `esc` key should invoke `onClose`. + * @default true + */ + var canEscapeKeyClose: Boolean? + /** + * Whether the overlay should prevent focus from leaving itself. That is, if the user attempts + * to focus an element outside the overlay and this prop is enabled, then the overlay will + * immediately bring focus back to itself. If you are nesting overlay components, either disable + * this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`. + * @default true + */ + var enforceFocus: Boolean? + /** + * If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached + * to the DOM when the overlay is opened for the first time; otherwise this happens when the + * component mounts. Lazy mounting provides noticeable performance improvements if you have lots + * of overlays at once, such as on each row of a table. + * @default true + */ + var lazy: Boolean? + /** + * Indicates how long (in milliseconds) the overlay's enter/leave transition takes. + * This is used by React `CSSTransition` to know when a transition completes and must match + * the duration of the animation in CSS. Only set this prop if you override Blueprint's default + * transitions with new transitions of a different length. + * @default 300 + */ + var transitionDuration: Int? + /** + * Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new + * element attached to `portalContainer` prop. + * + * This prop essentially determines which element is covered by the backdrop: if `false`, + * then only its parent is covered; otherwise, the entire page is covered (because the parent + * of the `Portal` is the `<body>` itself). + * + * Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they + * are rendered above their parents. + * @default true + */ + var usePortal: Boolean? + /** + * Space-delimited string of class names applied to the `Portal` element if + * `usePortal={true}`. + */ + var portalClassName: String? + /** + * The container element into which the overlay renders its contents, when `usePortal` is `true`. + * This prop is ignored if `usePortal` is `false`. + * @default document.body + */ + var portalContainer: HTMLElement? + /** + * A callback that is invoked when user interaction causes the overlay to close, such as + * clicking on the overlay or pressing the `esc` key (if enabled). + * + * Receives the event from the user's interaction, if there was an event (generally either a + * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it + * will not actually close itself until that prop becomes `false`. + */ + var onClose: ((Event) -> Unit)? +} +external interface IOverlayLifecycleProps { + /** + * Lifecycle method invoked just before the CSS _close_ transition begins on + * a child. Receives the DOM element of the child being closed. + */ + var onClosing: ((node: HTMLElement) -> Unit)? + /** + * Lifecycle method invoked just after the CSS _close_ transition ends but + * before the child has been removed from the DOM. Receives the DOM element + * of the child being closed. + */ + var onClosed: ((node: HTMLElement) -> Unit)? + /** + * Lifecycle method invoked just after mounting the child in the DOM but + * just before the CSS _open_ transition begins. Receives the DOM element of + * the child being opened. + */ + var onOpening: ((node: HTMLElement) -> Unit)? + /** + * Lifecycle method invoked just after the CSS _open_ transition ends. + * Receives the DOM element of the child being opened. + */ + var onOpened: ((node: HTMLElement) -> Unit)? +} +external interface IBackdropProps { + /** CSS class names to apply to backdrop element. */ + var backdropClassName: String? + /** HTML props for the backdrop element. */ + var backdropProps: RProps? //React.HTMLProps<HTMLDivElement>? + /** + * Whether clicking outside the overlay element (either on backdrop when present or on document) + * should invoke `onClose`. + * @default true + */ + var canOutsideClickClose: Boolean? + /** + * Whether a container-spanning backdrop element should be rendered behind the contents. + * @default true + */ + var hasBackdrop: Boolean? +} +external interface IOverlayProps : IOverlayableProps, IBackdropProps, IProps { + /** + * Toggles the visibility of the overlay and its children. + * This prop is required because the component is controlled. + */ + var isOpen: Boolean + /** + * Name of the transition for internal `CSSTransition`. + * Providing your own name here will require defining new CSS transition properties. + * @default Classes.OVERLAY + */ + var transitionName: String? +} +external interface IOverlayState : RState { + var hasEverOpened: Boolean? +} +external class Overlay : PureComponent<IOverlayProps, IOverlayState> { + override fun render(): ReactElement +}
\ No newline at end of file diff --git a/sw-ui/src/main/kotlin/blueprintjsHelpers.kt b/sw-ui/src/main/kotlin/blueprintjsHelpers.kt new file mode 100644 index 00000000..da6b6914 --- /dev/null +++ b/sw-ui/src/main/kotlin/blueprintjsHelpers.kt @@ -0,0 +1,137 @@ +package com.palantir.blueprintjs + +import org.w3c.dom.events.Event +import org.w3c.dom.events.MouseEvent +import react.RBuilder +import react.RHandler +import react.ReactElement + +typealias IconName = String + +fun RBuilder.bpIcon( + name: IconName, + size: Int = Icon.SIZE_STANDARD, + intent: Intent = Intent.NONE, + title: String? = null, + alt: String? = null, + className: String? = null, + block: RHandler<IIconProps> = {} +): ReactElement = child(Icon::class) { + attrs { + this.icon = name + this.iconSize = size + this.htmlTitle = title + this.intent = intent + this.title = alt + this.className = className + } + block() +} + +fun RBuilder.bpButton( + minimal: Boolean = false, + small: Boolean = false, + large: Boolean = false, + disabled: Boolean = false, + title: String? = null, + icon: IconName? = null, + rightIcon: IconName? = null, + intent: Intent = Intent.NONE, + onClick: ((event: MouseEvent) -> Unit)? = {}, + block: RHandler<IButtonProps> = {} +): ReactElement = child(Button::class) { + attrs { + this.title = title + this.minimal = minimal + this.small = small + this.large = large + this.disabled = disabled + this.icon = icon + this.rightIcon = rightIcon + this.intent = intent + this.onClick = onClick + } + block() +} + +fun RBuilder.bpButtonGroup( + large: Boolean = false, + minimal: Boolean = false, + block: RHandler<IButtonGroupProps> = {} +): ReactElement = child(ButtonGroup::class) { + attrs { + this.large = large + this.minimal = minimal + } + block() +} + +fun RBuilder.bpInputGroup( + large: Boolean = false, + placeholder: String = "", + rightElement: ReactElement? = null, + onChange: (Event) -> Unit +): ReactElement = child(InputGroup::class) { + attrs { + this.large = large + this.placeholder = placeholder + this.rightElement = rightElement + this.onChange = onChange + } +} + +fun RBuilder.bpTag( + intent: Intent? = null, + minimal: Boolean? = null, + active: Boolean? = null, + block: RHandler<ITagProps> = {} +): ReactElement = child(Tag::class) { + attrs { + this.intent = intent + this.minimal = minimal + this.active = active + } + block() +} + +fun RBuilder.bpNonIdealState( + icon: IconName? = null, + title: ReactElement? = null, + description: ReactElement? = null, + action: ReactElement? = null, + children: ReactElement? = null, + block: RHandler<INonIdealStateProps> = {} +): ReactElement = child(NonIdealState::class) { + attrs { + this.icon = icon + this.title = title + this.description = description + this.action = action + this.children = children + } + block() +} + +fun RBuilder.bpOverlay( + isOpen: Boolean, + autoFocus: Boolean = true, + enforceFocus: Boolean = true, + usePortal: Boolean = true, + hasBackdrop: Boolean = true, + canEscapeKeyClose: Boolean = true, + canOutsideClickClose: Boolean = true, + onClose: () -> Unit = {}, + block: RHandler<IOverlayProps> = {} +): ReactElement = child(Overlay::class) { + attrs { + this.isOpen = isOpen + this.autoFocus = autoFocus + this.enforceFocus = enforceFocus + this.usePortal = usePortal + this.hasBackdrop = hasBackdrop + this.canEscapeKeyClose = canEscapeKeyClose + this.canOutsideClickClose = canOutsideClickClose + this.onClose = { onClose() } + } + block() +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt new file mode 100644 index 00000000..8b38e010 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt @@ -0,0 +1,49 @@ +package org.luxons.sevenwonders.ui + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.luxons.sevenwonders.ui.components.application +import org.luxons.sevenwonders.ui.redux.SwState +import org.luxons.sevenwonders.ui.redux.configureStore +import org.luxons.sevenwonders.ui.redux.sagas.SagaManager +import org.luxons.sevenwonders.ui.redux.sagas.rootSaga +import org.w3c.dom.Element +import react.dom.* +import react.redux.provider +import redux.RAction +import redux.Store +import redux.WrapperAction +import kotlin.browser.document +import kotlin.browser.window + +fun main() { + window.onload = { + val rootElement = document.getElementById("root") + if (rootElement != null) { + initializeAndRender(rootElement) + } else { + console.error("Element with ID 'root' was not found, cannot bootstrap react app") + } + } +} + +private fun initializeAndRender(rootElement: Element) { + val store = initRedux() + + render(rootElement) { + provider(store) { + application() + } + } +} + +private fun initRedux(): Store<SwState, RAction, WrapperAction> { + val sagaManager = SagaManager<SwState, RAction, WrapperAction>() + val store = configureStore(sagaManager = sagaManager) + GlobalScope.launch { + sagaManager.launchSaga(this) { + rootSaga() + } + } + return store +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt new file mode 100644 index 00000000..b1244b5c --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt @@ -0,0 +1,22 @@ +package org.luxons.sevenwonders.ui.components + +import org.luxons.sevenwonders.ui.components.game.gameScene +import org.luxons.sevenwonders.ui.components.gameBrowser.gameBrowser +import org.luxons.sevenwonders.ui.components.home.home +import org.luxons.sevenwonders.ui.components.lobby.lobby +import org.luxons.sevenwonders.ui.router.Route +import react.RBuilder +import react.router.dom.hashRouter +import react.router.dom.redirect +import react.router.dom.route +import react.router.dom.switch + +fun RBuilder.application() = hashRouter { + switch { + route(Route.GAME_BROWSER.path) { gameBrowser() } + route(Route.GAME.path) { gameScene() } + route(Route.LOBBY.path) { lobby() } + route(Route.HOME.path, exact = true) { home() } + redirect(from = "*", to = "/") + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt new file mode 100644 index 00000000..f5b16248 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt @@ -0,0 +1,36 @@ +package org.luxons.sevenwonders.ui.components + +import kotlinx.css.Overflow +import kotlinx.css.Position +import kotlinx.css.bottom +import kotlinx.css.left +import kotlinx.css.overflow +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.transform +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.right +import kotlinx.css.top +import styled.StyleSheet + +object GlobalStyles : StyleSheet("GlobalStyles", isStatic = true) { + + val fullscreen by css { + position = Position.fixed + top = 0.px + left = 0.px + bottom = 0.px + right = 0.px + overflow = Overflow.hidden + } + + val fixedCenter by css { + position = Position.fixed + left = 50.pct + top = 50.pct + transform { + translate((-50).pct, (-50).pct) + } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt new file mode 100644 index 00000000..dd67757a --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt @@ -0,0 +1,124 @@ +package org.luxons.sevenwonders.ui.components.game + +import kotlinx.css.Color +import kotlinx.css.Display +import kotlinx.css.Position +import kotlinx.css.TextAlign +import kotlinx.css.display +import kotlinx.css.height +import kotlinx.css.margin +import kotlinx.css.maxHeight +import kotlinx.css.maxWidth +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.boxShadow +import kotlinx.css.properties.transform +import kotlinx.css.properties.translate +import kotlinx.css.rem +import kotlinx.css.textAlign +import kotlinx.css.vh +import kotlinx.css.vw +import kotlinx.css.width +import kotlinx.css.zIndex +import kotlinx.html.DIV +import kotlinx.html.title +import org.luxons.sevenwonders.model.boards.Board +import org.luxons.sevenwonders.model.cards.TableCard +import org.luxons.sevenwonders.model.wonders.ApiWonder +import react.RBuilder +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv +import styled.styledImg + +// card offsets in % of their size when displayed in columns +private const val xOffset = 20 +private const val yOffset = 21 + +fun RBuilder.boardComponent(board: Board) { + styledDiv { + css { + width = 100.vw + } + tableCards(cardColumns = board.playedCards) + wonderComponent(wonder = board.wonder) + } +} + +private fun RBuilder.tableCards(cardColumns: List<List<TableCard>>) { + styledDiv { + css { + display = Display.flex + height = 40.vh + width = 100.vw + } + cardColumns.forEach { cards -> + tableCardColumn(cards = cards) { + attrs { + key = cards.first().color.toString() + } + } + } + } +} + +private fun RBuilder.tableCardColumn(cards: List<TableCard>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + css { + height = 40.vh + width = 15.vw + margin = "auto" + position = Position.relative + } + block() + cards.forEachIndexed { index, card -> + tableCard(card = card, indexInColumn = index) { + attrs { key = card.name } + } + } + } +} + +private fun RBuilder.tableCard(card: TableCard, indexInColumn: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + css { + position = Position.absolute + zIndex = indexInColumn + transform { + translate( + tx = (indexInColumn * xOffset).pct, + ty = (indexInColumn * yOffset).pct + ) + } + } + block() + val highlightColor = if (card.playedDuringLastMove) Color.gold else null + cardImage(card = card, highlightColor = highlightColor) { + css { + maxWidth = 10.vw + maxHeight = 25.vh + } + } + } +} + +private fun RBuilder.wonderComponent(wonder: ApiWonder) { + styledDiv { + css { + width = 100.vw + textAlign = TextAlign.center + } + styledImg(src="/images/wonders/${wonder.image}") { + css { + declarations["border-radius"] = "0.5%/1.5%" + boxShadow(color = Color.black, offsetX = 0.2.rem, offsetY = 0.2.rem, blurRadius = 0.5.rem) + maxHeight = 30.vh + maxWidth = 95.vw + } + attrs { + this.title = wonder.name + this.alt = "Wonder ${wonder.name}" + } + } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt new file mode 100644 index 00000000..38afe028 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt @@ -0,0 +1,43 @@ +package org.luxons.sevenwonders.ui.components.game + +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.borderRadius +import kotlinx.css.pct +import kotlinx.css.properties.boxShadow +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.html.IMG +import kotlinx.html.title +import org.luxons.sevenwonders.model.cards.Card +import react.RBuilder +import styled.StyledDOMBuilder +import styled.css +import styled.styledImg + +fun RBuilder.cardImage(card: Card, highlightColor: Color? = null, block: StyledDOMBuilder<IMG>.() -> Unit = {}) { + styledImg(src = "/images/cards/${card.image}") { + css { + borderRadius = 5.pct + boxShadow(offsetX = 2.px, offsetY = 2.px, blurRadius = 5.px, color = Color.black) + highlightStyle(highlightColor) + } + attrs { + title = card.name + alt = "Card ${card.name}" + } + block() + } +} + +private fun CSSBuilder.highlightStyle(highlightColor: Color?) { + if (highlightColor != null) { + boxShadow( + offsetX = 0.px, + offsetY = 0.px, + blurRadius = 1.rem, + spreadRadius = 0.1.rem, + color = highlightColor + ) + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt new file mode 100644 index 00000000..d54a0240 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt @@ -0,0 +1,178 @@ +package org.luxons.sevenwonders.ui.components.game + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpButtonGroup +import com.palantir.blueprintjs.bpOverlay +import kotlinx.css.Position +import kotlinx.css.background +import kotlinx.css.backgroundSize +import kotlinx.css.bottom +import kotlinx.css.left +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.transform +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.css.right +import kotlinx.css.top +import kotlinx.html.DIV +import org.luxons.sevenwonders.model.Action +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.model.api.PlayerDTO +import org.luxons.sevenwonders.model.cards.HandCard +import org.luxons.sevenwonders.ui.components.GlobalStyles +import org.luxons.sevenwonders.ui.redux.GameState +import org.luxons.sevenwonders.ui.redux.RequestPrepareMove +import org.luxons.sevenwonders.ui.redux.RequestSayReady +import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove +import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch +import react.RBuilder +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.ReactElement +import react.dom.* +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv + +interface GameSceneStateProps: RProps { + var playerIsReady: Boolean + var players: List<PlayerDTO> + var gameState: GameState? + var preparedMove: PlayerMove? + var preparedCard: HandCard? +} + +interface GameSceneDispatchProps: RProps { + var sayReady: () -> Unit + var prepareMove: (move: PlayerMove) -> Unit + var unprepareMove: () -> Unit +} + +interface GameSceneProps : GameSceneStateProps, GameSceneDispatchProps + +private class GameScene(props: GameSceneProps) : RComponent<GameSceneProps, RState>(props) { + + override fun RBuilder.render() { + styledDiv { + css { + background = "url('images/background-papyrus3.jpg')" + backgroundSize = "cover" + +GlobalStyles.fullscreen + } + val turnInfo = props.gameState?.turnInfo + if (turnInfo == null) { + p { +"Error: no turn info data"} + } else { + turnInfoScene(turnInfo) + } + } + } + + private fun RBuilder.sayReadyButton(): ReactElement { + val isReady = props.playerIsReady + val intent = if (isReady) Intent.SUCCESS else Intent.PRIMARY + return styledDiv { + css { + position = Position.absolute + bottom = 6.rem + left = 50.pct + transform { translate(tx = (-50).pct) } + } + bpButtonGroup { + bpButton( + large = true, + disabled = isReady, + intent = intent, + icon = if (isReady) "tick-circle" else "play", + onClick = { props.sayReady() } + ) { + +"READY" + } + // not really a button, but nice for style + bpButton( + large = true, + icon = "people", + disabled = isReady, + intent = intent + ) { + +"${props.players.count { it.isReady }}/${props.players.size}" + } + } + } + } + + private fun RBuilder.turnInfoScene(turnInfo: PlayerTurnInfo) { + val board = turnInfo.table.boards[turnInfo.playerIndex] + div { + // TODO use blueprint's Callout component without header and primary intent + p { + turnInfo.message } + boardComponent(board = board) + val hand = turnInfo.hand + if (hand != null) { + handComponent( + cards = hand, + wonderUpgradable = turnInfo.wonderBuildability.isBuildable, + preparedMove = props.preparedMove, + prepareMove = props.prepareMove + ) + } + val card = props.preparedCard + if (card != null) { + preparedMove(card) + } + if (turnInfo.action == Action.SAY_READY) { + sayReadyButton() + } + productionBar(gold = board.gold, production = board.production) + } + } + + private fun RBuilder.preparedMove(card: HandCard) { + bpOverlay(isOpen = true, onClose = props.unprepareMove) { + styledDiv { + css { +GlobalStyles.fixedCenter } + cardImage(card) + styledDiv { + css { + position = Position.absolute + top = 0.px + right = 0.px + } + bpButton( + icon = "cross", + title = "Cancel prepared move", + small = true, + intent = Intent.DANGER, + onClick = { props.unprepareMove() } + ) + } + } + } + } +} + +fun RBuilder.gameScene() = gameScene {} + +private val gameScene: RClass<GameSceneProps> = connectStateAndDispatch<GameSceneStateProps, GameSceneDispatchProps, + GameSceneProps>( + clazz = GameScene::class, + mapDispatchToProps = { dispatch, _ -> + prepareMove = { move -> dispatch(RequestPrepareMove(move)) } + unprepareMove = { dispatch(RequestUnprepareMove()) } + sayReady = { dispatch(RequestSayReady()) } + }, + mapStateToProps = { state, _ -> + playerIsReady = state.currentPlayer?.isReady == true + players = state.gameState?.players ?: emptyList() + gameState = state.gameState + preparedMove = state.gameState?.currentPreparedMove + preparedCard = state.gameState?.currentPreparedCard + } +) + diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt new file mode 100644 index 00000000..17ceffd2 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt @@ -0,0 +1,174 @@ +package org.luxons.sevenwonders.ui.components.game + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpButtonGroup +import kotlinx.css.Align +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.Display +import kotlinx.css.GridColumn +import kotlinx.css.GridRow +import kotlinx.css.Position +import kotlinx.css.alignItems +import kotlinx.css.bottom +import kotlinx.css.display +import kotlinx.css.filter +import kotlinx.css.gridColumn +import kotlinx.css.gridRow +import kotlinx.css.height +import kotlinx.css.left +import kotlinx.css.margin +import kotlinx.css.maxHeight +import kotlinx.css.maxWidth +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.boxShadow +import kotlinx.css.properties.s +import kotlinx.css.properties.transform +import kotlinx.css.properties.transition +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.css.vh +import kotlinx.css.vw +import kotlinx.css.width +import kotlinx.css.zIndex +import kotlinx.html.DIV +import org.luxons.sevenwonders.model.MoveType +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.cards.HandCard +import org.luxons.sevenwonders.model.cards.PreparedCard +import org.luxons.sevenwonders.ui.components.game.cardImage +import react.RBuilder +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv + +fun RBuilder.handComponent( + cards: List<HandCard>, + wonderUpgradable: Boolean, + preparedMove: PlayerMove?, + prepareMove: (PlayerMove) -> Unit +) { + styledDiv { + css { + handStyle() + } + cards.filter { it.name != preparedMove?.cardName }.forEachIndexed { index, c -> + handCard( + card = c, + wonderUpgradable = wonderUpgradable, + prepareMove = prepareMove + ) { + attrs { + key = index.toString() + } + } + } + } +} + +private fun RBuilder.handCard( + card: HandCard, + wonderUpgradable: Boolean, + prepareMove: (PlayerMove) -> Unit, + block: StyledDOMBuilder<DIV>.() -> Unit +) { + styledDiv { + css { + handCardStyle() + } + block() + cardImage(card) { + css { + handCardImgStyle(card.playability.isPlayable) + } + } + actionButtons(card, wonderUpgradable, prepareMove) + } +} + +private fun RBuilder.actionButtons(card: HandCard, wonderUpgradable: Boolean, prepareMove: (PlayerMove) -> Unit) { + // class: action-buttons + styledDiv { + css { + alignItems = Align.flexEnd + display = Display.none + gridRow = GridRow("1") + gridColumn = GridColumn("1") + + ancestorHover(".hand-card") { + display = Display.flex + } + } + bpButtonGroup { + bpButton(title = "PLAY", + large = true, + intent = Intent.SUCCESS, + icon = "play", + disabled = !card.playability.isPlayable, + onClick = { prepareMove(PlayerMove(MoveType.PLAY, card.name)) }) + bpButton(title = "UPGRADE WONDER", + large = true, + intent = Intent.PRIMARY, + icon = "key-shift", + disabled = !wonderUpgradable, + onClick = { prepareMove(PlayerMove(MoveType.UPGRADE_WONDER, card.name)) }) + bpButton(title = "DISCARD", + large = true, + intent = Intent.DANGER, + icon = "cross", + onClick = { prepareMove(PlayerMove(MoveType.DISCARD, card.name)) }) + } + } +} + +private fun CSSBuilder.handStyle() { + alignItems = Align.center + bottom = 0.px + display = Display.flex + height = 345.px + left = 50.pct + maxHeight = 25.vw + position = Position.absolute + transform { + translate(tx = (-50).pct, ty = 55.pct) + } + transition(duration = 0.5.s) + zIndex = 30 + + hover { + bottom = 4.rem + transform { + translate(tx = (-50).pct, ty = 0.pct) + } + } +} + +private fun CSSBuilder.handCardStyle() { + classes.add("hand-card") + alignItems = Align.flexEnd + display = Display.grid + margin(all = 0.2.rem) +} + +private fun CSSBuilder.handCardImgStyle(isPlayable: Boolean) { + gridRow = GridRow("1") + gridColumn = GridColumn("1") + maxWidth = 13.vw + maxHeight = 60.vh + transition(duration = 0.1.s) + width = 11.rem + + ancestorHover(".hand-card") { + boxShadow(offsetX = 0.px, offsetY = 10.px, blurRadius = 40.px, color = Color.black) + width = 14.rem + maxWidth = 15.vw + maxHeight = 90.vh + } + + if (!isPlayable) { + filter = "grayscale(50%) contrast(50%)" + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt new file mode 100644 index 00000000..773e9835 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt @@ -0,0 +1,164 @@ +package org.luxons.sevenwonders.ui.components.game + +import kotlinx.css.Align +import kotlinx.css.BorderStyle +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.Display +import kotlinx.css.Position +import kotlinx.css.VerticalAlign +import kotlinx.css.alignItems +import kotlinx.css.background +import kotlinx.css.bottom +import kotlinx.css.color +import kotlinx.css.display +import kotlinx.css.fontFamily +import kotlinx.css.fontSize +import kotlinx.css.height +import kotlinx.css.margin +import kotlinx.css.marginLeft +import kotlinx.css.position +import kotlinx.css.properties.borderTop +import kotlinx.css.properties.boxShadow +import kotlinx.css.px +import kotlinx.css.rem +import kotlinx.css.verticalAlign +import kotlinx.css.vw +import kotlinx.css.width +import kotlinx.css.zIndex +import kotlinx.html.DIV +import kotlinx.html.title +import org.luxons.sevenwonders.model.boards.Production +import org.luxons.sevenwonders.model.resources.CountedResource +import org.luxons.sevenwonders.model.resources.ResourceType +import react.RBuilder +import react.dom.* +import styled.StyledDOMBuilder +import styled.css +import styled.styledDiv +import styled.styledImg +import styled.styledSpan + +fun RBuilder.productionBar(gold: Int, production: Production) { + styledDiv { + css { + productionBarStyle() + } + goldIndicator(gold) + fixedResources(production.fixedResources) + alternativeResources(production.alternativeResources) + } +} + +private fun RBuilder.goldIndicator(amount: Int) { + tokenWithCount(tokenName = "coin", count = amount) +} + +private fun RBuilder.fixedResources(resources: List<CountedResource>) { + styledDiv { + css { + margin = "auto" + display = Display.flex + } + resources.forEach { + tokenWithCount(tokenName = getTokenName(it.type), count = it.count) { + attrs { key = it.type.toString() } + css { marginLeft = 1.rem } + } + } + } +} + +private fun RBuilder.alternativeResources(resources: Set<Set<ResourceType>>) { + styledDiv { + css { + margin = "auto" + display = Display.flex + } + resources.forEachIndexed { index, res -> + resourceChoice(types = res) { + attrs { + key = index.toString() + } + } + } + } +} + +private fun RBuilder.resourceChoice(types: Set<ResourceType>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + css { + marginLeft = (1.5).rem + } + block() + for ((i, t) in types.withIndex()) { + tokenImage(tokenName = getTokenName(t)) { + attrs { this.key = t.toString() } + } + if (i < types.indices.last) { + styledSpan { css { choiceSeparatorStyle() } } + } + } + } +} + +private fun RBuilder.tokenWithCount(tokenName: String, count: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledDiv { + block() + tokenImage(tokenName) + styledSpan { + css { tokenCountStyle() } + + "× $count" + } + } +} + +private fun RBuilder.tokenImage(tokenName: String, block: StyledDOMBuilder<DIV>.() -> Unit = {}) { + styledImg(src = getTokenImagePath(tokenName)) { + css { + tokenImageStyle() + } + attrs { + this.title = tokenName + this.alt = tokenName + } + } +} + +private fun getTokenImagePath(tokenName: String)= "/images/tokens/${tokenName}.png" + +private fun getTokenName(resourceType: ResourceType)= "resources/${resourceType.toString().toLowerCase()}" + +private fun CSSBuilder.productionBarStyle() { + alignItems = Align.center + background = "linear-gradient(#eaeaea, #888 7%)" + bottom = 0.px + borderTop(width = 1.px, color = Color("#8b8b8b"), style = BorderStyle.solid) + boxShadow(blurRadius = 15.px, color = Color("#747474")) + display = Display.flex + height = (3.5).rem + width = 100.vw + position = Position.fixed + zIndex = 99 +} + +private fun CSSBuilder.choiceSeparatorStyle() { + fontSize = 2.rem + verticalAlign = VerticalAlign.middle + margin(all = 5.px) + color = Color("#c29929") + declarations["text-shadow"] = "0 0 1px black" +} + +private fun CSSBuilder.tokenImageStyle() { + height = 3.rem + width = 3.rem + verticalAlign = VerticalAlign.middle +} + +private fun CSSBuilder.tokenCountStyle() { + fontFamily = "fantasy" + fontSize = 1.5.rem + verticalAlign = VerticalAlign.middle + marginLeft = 0.2.rem +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt new file mode 100644 index 00000000..876a167e --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt @@ -0,0 +1,76 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpInputGroup +import kotlinx.css.Display +import kotlinx.css.FlexDirection +import kotlinx.css.JustifyContent +import kotlinx.css.display +import kotlinx.css.flexDirection +import kotlinx.css.justifyContent +import kotlinx.html.js.onSubmitFunction +import org.luxons.sevenwonders.ui.redux.RequestCreateGame +import org.luxons.sevenwonders.ui.redux.connectDispatch +import org.luxons.sevenwonders.ui.utils.createElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event +import react.RBuilder +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.dom.* +import styled.css +import styled.styledDiv + +private interface CreateGameFormProps: RProps { + var createGame: (String) -> Unit +} + +private data class CreateGameFormState(var gameName: String = ""): RState + +private class CreateGameForm(props: CreateGameFormProps): RComponent<CreateGameFormProps, CreateGameFormState>(props) { + + override fun CreateGameFormState.init(props: CreateGameFormProps) { + gameName = "" + } + + override fun RBuilder.render() { + styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.row + justifyContent = JustifyContent.spaceBetween + } + form { + attrs { + onSubmitFunction = { e -> createGame(e) } + } + + bpInputGroup( + placeholder = "Game name", + onChange = { e -> + val input = e.currentTarget as HTMLInputElement + setState(transformState = { CreateGameFormState(input.value) }) + }, + rightElement = createGameButton() + ) + } + playerInfo() + } + } + + private fun createGameButton() = createElement { + bpButton(minimal = true, intent = Intent.PRIMARY, icon = "add", onClick = { e -> createGame(e) }) + } + + private fun createGame(e: Event) { + e.preventDefault() // prevents refreshing the page when pressing Enter + props.createGame(state.gameName) + } +} + +val createGameForm: RClass<RProps> = connectDispatch(CreateGameForm::class) { dispatch, _ -> + createGame = { name -> dispatch(RequestCreateGame(name)) } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt new file mode 100644 index 00000000..2f860ca7 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt @@ -0,0 +1,10 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import react.RBuilder +import react.dom.* + +fun RBuilder.gameBrowser() = div { + h1 { +"Games" } + createGameForm {} + gameList() +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt new file mode 100644 index 00000000..47c17da1 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt @@ -0,0 +1,133 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import com.palantir.blueprintjs.Classes +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpIcon +import com.palantir.blueprintjs.bpTag +import kotlinx.css.Align +import kotlinx.css.Display +import kotlinx.css.FlexDirection +import kotlinx.css.VerticalAlign +import kotlinx.css.alignItems +import kotlinx.css.display +import kotlinx.css.flexDirection +import kotlinx.css.verticalAlign +import kotlinx.html.classes +import kotlinx.html.title +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.api.State +import org.luxons.sevenwonders.ui.redux.RequestJoinGame +import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch +import react.RBuilder +import react.RComponent +import react.RProps +import react.RState +import react.dom.* +import styled.css +import styled.styledDiv +import styled.styledSpan +import styled.styledTr + +interface GameListStateProps : RProps { + var connectedPlayer: ConnectedPlayer + var games: List<LobbyDTO> +} + +interface GameListDispatchProps: RProps { + var joinGame: (Long) -> Unit +} + +interface GameListProps : GameListStateProps, GameListDispatchProps + +class GameListPresenter(props: GameListProps) : RComponent<GameListProps, RState>(props) { + + override fun RBuilder.render() { + table { + attrs { + classes = setOf(Classes.HTML_TABLE) + } + thead { + gameListHeaderRow() + } + tbody { + props.games.forEach { + gameListItemRow(it, props.joinGame) + } + } + } + } + + private fun RBuilder.gameListHeaderRow() = tr { + th { +"Name" } + th { +"Status" } + th { +"Nb Players" } + th { +"Join" } + } + + private fun RBuilder.gameListItemRow(lobby: LobbyDTO, joinGame: (Long) -> Unit) = styledTr { + css { + verticalAlign = VerticalAlign.middle + } + attrs { + key = lobby.id.toString() + } + td { +lobby.name } + td { gameStatus(lobby.state) } + td { playerCount(lobby.players.size) } + td { joinButton(lobby) } + } + + private fun RBuilder.gameStatus(state: State) { + val intent = when(state) { + State.LOBBY -> Intent.SUCCESS + State.PLAYING -> Intent.WARNING + State.FINISHED -> Intent.DANGER + } + bpTag(minimal = true, intent = intent) { + +state.toString() + } + } + + private fun RBuilder.playerCount(nPlayers: Int) { + styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.row + alignItems = Align.center + } + attrs { + title = "Number of players" + } + bpIcon(name = "people", title = null) + styledSpan { + +nPlayers.toString() + } + } + } + + private fun RBuilder.joinButton(lobby: LobbyDTO) { + val joinability = lobby.joinability(props.connectedPlayer.displayName) + bpButton( + minimal = true, + title = joinability.tooltip, + icon = "arrow-right", + disabled = !joinability.canDo, + onClick = { props.joinGame(lobby.id) } + ) + } +} + +fun RBuilder.gameList() = gameList {} + +private val gameList = connectStateAndDispatch<GameListStateProps, GameListDispatchProps, GameListProps>( + clazz = GameListPresenter::class, + mapStateToProps = { state, _ -> + connectedPlayer = state.connectedPlayer ?: error("there should be a connected player") + games = state.games + }, + mapDispatchToProps = { dispatch, _ -> + joinGame = { gameId -> dispatch(RequestJoinGame(gameId = gameId)) } + } +) diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt new file mode 100644 index 00000000..b939dfe1 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt @@ -0,0 +1,36 @@ +package org.luxons.sevenwonders.ui.components.gameBrowser + +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.ui.redux.connectState +import react.RBuilder +import react.RComponent +import react.RProps +import react.RState +import react.dom.* + +interface PlayerInfoProps : RProps { + var connectedPlayer: ConnectedPlayer? +} + +class PlayerInfoPresenter(props: PlayerInfoProps) : RComponent<PlayerInfoProps, RState>(props) { + + override fun RBuilder.render() { + span { + b { + +"Username:" + } + props.connectedPlayer?.let { + + " ${it.displayName}" + } + } + } +} + +fun RBuilder.playerInfo() = playerInfo {} + +private val playerInfo = connectState( + clazz = PlayerInfoPresenter::class, + mapStateToProps = { state, _ -> + connectedPlayer = state.connectedPlayer + } +) diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt new file mode 100644 index 00000000..1aa4be43 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt @@ -0,0 +1,64 @@ +package org.luxons.sevenwonders.ui.components.home + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import com.palantir.blueprintjs.bpInputGroup +import kotlinx.html.js.onSubmitFunction +import org.luxons.sevenwonders.ui.redux.RequestChooseName +import org.luxons.sevenwonders.ui.redux.connectDispatch +import org.luxons.sevenwonders.ui.utils.createElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event +import react.RBuilder +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.ReactElement +import react.dom.* + +private interface ChooseNameFormProps: RProps { + var chooseUsername: (String) -> Unit +} + +private data class ChooseNameFormState(var username: String = ""): RState + +private class ChooseNameForm(props: ChooseNameFormProps): RComponent<ChooseNameFormProps, ChooseNameFormState>(props) { + + override fun ChooseNameFormState.init(props: ChooseNameFormProps) { + username = "" + } + + override fun RBuilder.render() { + form { + attrs.onSubmitFunction = { e -> chooseUsername(e) } + bpInputGroup( + large = true, + placeholder = "Username", + rightElement = submitButton(), + onChange = { e -> + val input = e.currentTarget as HTMLInputElement + setState(transformState = { ChooseNameFormState(input.value) }) + } + ) + } + } + + private fun submitButton(): ReactElement = createElement { + bpButton( + minimal = true, + icon = "arrow-right", + intent = Intent.PRIMARY, + onClick = { e -> chooseUsername(e) } + ) + } + + private fun chooseUsername(e: Event) { + e.preventDefault() + props.chooseUsername(state.username) + } +} + +val chooseNameForm: RClass<RProps> = connectDispatch(ChooseNameForm::class) { dispatch, _ -> + chooseUsername = { name -> dispatch(RequestChooseName(name)) } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt new file mode 100644 index 00000000..43a1592b --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt @@ -0,0 +1,21 @@ +package org.luxons.sevenwonders.ui.components.home + +import org.luxons.sevenwonders.ui.components.GlobalStyles +import react.RBuilder +import react.dom.* +import styled.css +import styled.styledDiv + +private const val LOGO = "images/logo-7-wonders.png" + +fun RBuilder.home() = styledDiv { + css { + +GlobalStyles.fullscreen + +HomeStyles.centerChildren + +HomeStyles.zeusBackground + } + + img(src = LOGO, alt = "Seven Wonders") {} + + chooseNameForm {} +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt new file mode 100644 index 00000000..624f430c --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt @@ -0,0 +1,19 @@ +package org.luxons.sevenwonders.ui.components.home + +import kotlinx.css.* +import styled.StyleSheet + +object HomeStyles : StyleSheet("HomeStyles", isStatic = true) { + + val zeusBackground by css { + background = "url('images/background-zeus-temple.jpg') center no-repeat" + backgroundSize = "cover" + } + + val centerChildren by css { + display = Display.flex + flexDirection = FlexDirection.column + alignItems = Align.center + justifyContent = JustifyContent.center + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt new file mode 100644 index 00000000..5b13d8b1 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt @@ -0,0 +1,66 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpButton +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.api.PlayerDTO +import org.luxons.sevenwonders.ui.redux.RequestStartGame +import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch +import react.RBuilder +import react.RComponent +import react.RProps +import react.RState +import react.dom.* + +interface LobbyStateProps : RProps { + var currentGame: LobbyDTO? + var currentPlayer: PlayerDTO? +} + +interface LobbyDispatchProps : RProps { + var startGame: () -> Unit +} + +interface LobbyProps : LobbyDispatchProps, LobbyStateProps + +class LobbyPresenter(props: LobbyProps) : RComponent<LobbyProps, RState>(props) { + + override fun RBuilder.render() { + val currentGame = props.currentGame + val currentPlayer = props.currentPlayer + if (currentGame == null || currentPlayer == null) { + div { +"Error: no current game." } + return + } + div { + h2 { +"${currentGame.name} — Lobby" } + radialPlayerList(currentGame.players, currentPlayer) + if (currentPlayer.isGameOwner) { + val startability = currentGame.startability(currentPlayer.username) + bpButton( + large = true, + intent = Intent.PRIMARY, + icon = "play", + title = startability.tooltip, + disabled = !startability.canDo, + onClick = { props.startGame() } + ) { + + "START" + } + } + } + } +} + +fun RBuilder.lobby() = lobby {} + +private val lobby = connectStateAndDispatch<LobbyStateProps, LobbyDispatchProps, LobbyProps>( + clazz = LobbyPresenter::class, + mapStateToProps = { state, _ -> + currentGame = state.currentLobby + currentPlayer = state.currentPlayer + }, + mapDispatchToProps = { dispatch, _ -> + startGame = { dispatch(RequestStartGame()) } + } +) diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt new file mode 100644 index 00000000..be3bb1de --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt @@ -0,0 +1,121 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import kotlinx.css.CSSBuilder +import kotlinx.css.Display +import kotlinx.css.ListStyleType +import kotlinx.css.Position +import kotlinx.css.display +import kotlinx.css.height +import kotlinx.css.left +import kotlinx.css.listStyleType +import kotlinx.css.margin +import kotlinx.css.padding +import kotlinx.css.pct +import kotlinx.css.position +import kotlinx.css.properties.Timing +import kotlinx.css.properties.ms +import kotlinx.css.properties.transform +import kotlinx.css.properties.transition +import kotlinx.css.properties.translate +import kotlinx.css.px +import kotlinx.css.top +import kotlinx.css.width +import kotlinx.css.zIndex +import react.RBuilder +import react.ReactElement +import react.dom.* +import styled.css +import styled.styledDiv +import styled.styledLi +import styled.styledUl + +typealias ElementBuilder = RBuilder.() -> ReactElement + +fun RBuilder.radialList( + itemBuilders: List<ElementBuilder>, + centerElementBuilder: ElementBuilder, + itemWidth: Int, + itemHeight: Int, + options: RadialConfig = RadialConfig() +): ReactElement { + val containerWidth = options.diameter + itemWidth + val containerHeight = options.diameter + itemHeight + + return styledDiv { + css { + zeroMargins() + position = Position.relative + width = containerWidth.px + height = containerHeight.px + } + radialListItems(itemBuilders, options) + radialListCenter(centerElementBuilder) + } +} + +private fun RBuilder.radialListItems(itemBuilders: List<ElementBuilder>, radialConfig: RadialConfig): ReactElement { + val offsets = offsetsFromCenter(itemBuilders.size, radialConfig) + return styledUl { + css { + zeroMargins() + transition(property = "all", duration = 500.ms, timing = Timing.easeInOut) + zIndex = 1 + width = radialConfig.diameter.px + height = radialConfig.diameter.px + absoluteCenter() + } + itemBuilders.forEachIndexed { i, itemBuilder -> + radialListItem(itemBuilder, i, offsets[i]) + } + } +} + +private fun RBuilder.radialListItem(itemBuilder: ElementBuilder, i: Int, offset: CartesianCoords): ReactElement { + return styledLi { + css { + display = Display.block + position = Position.absolute + top = 50.pct + left = 50.pct + zeroMargins() + listStyleType = ListStyleType.unset + transition("all", 500.ms, Timing.easeInOut) + zIndex = 1 + transform { + translate(offset.x.px, offset.y.px) + translate((-50).pct, (-50).pct) + } + } + attrs { + key = "$i" + } + itemBuilder() + } +} + +private fun RBuilder.radialListCenter(centerElement: ElementBuilder?): ReactElement? { + if (centerElement == null) { + return null + } + return styledDiv { + css { + zIndex = 0 + absoluteCenter() + } + centerElement() + } +} + +private fun CSSBuilder.absoluteCenter() { + position = Position.absolute + left = 50.pct + top = 50.pct + transform { + translate((-50).pct, (-50).pct) + } +} + +private fun CSSBuilder.zeroMargins() { + margin = "0" + padding = "0" +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt new file mode 100644 index 00000000..d668ab9b --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt @@ -0,0 +1,57 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin + +data class CartesianCoords( + val x: Int, + val y: Int +) + +data class PolarCoords( + val radius: Int, + val angleDeg: Int +) + +private fun Int.toRadians() = (this * PI / 180.0) +private fun Double.project(angleRad: Double, trigFn: (Double) -> Double) = (this * trigFn(angleRad)).roundToInt() +private fun Double.xProjection(angleRad: Double) = project(angleRad, ::cos) +private fun Double.yProjection(angleRad: Double) = project(angleRad, ::sin) + +private fun PolarCoords.toCartesian() = CartesianCoords( + x = radius.toDouble().xProjection(angleDeg.toRadians()), + y = radius.toDouble().yProjection(angleDeg.toRadians()) +) + +// Y-axis is pointing down in the browser, so the directions need to be reversed +// (positive angles are now clockwise) +enum class Direction(private val value: Int) { + CLOCKWISE(1), + COUNTERCLOCKWISE(-1); + + fun toOrientedDegrees(deg: Int) = value * deg +} + +data class RadialConfig( + val radius: Int = 120, + val spreadArcDegrees: Int = 360, // full circle + val firstItemAngleDegrees: Int = 0, // 12 o'clock + val direction: Direction = Direction.CLOCKWISE +) { + val diameter: Int = radius * 2 +} + +private const val DEFAULT_START = -90 // Up, because Y-axis is reversed + +fun offsetsFromCenter(nbItems: Int, radialConfig: RadialConfig = RadialConfig()): List<CartesianCoords> { + val startAngle = DEFAULT_START + radialConfig.direction.toOrientedDegrees(radialConfig.firstItemAngleDegrees) + val angleStep = radialConfig.spreadArcDegrees / nbItems + return List(nbItems) { itemCartesianOffsets(startAngle, angleStep, it, radialConfig) } +} + +private fun itemCartesianOffsets(startAngle: Int, angleStep: Int, index: Int, config: RadialConfig): CartesianCoords { + val itemAngle = startAngle + config.direction.toOrientedDegrees(angleStep) * index + return PolarCoords(config.radius, itemAngle).toCartesian() +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt new file mode 100644 index 00000000..ff541696 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt @@ -0,0 +1,106 @@ +package org.luxons.sevenwonders.ui.components.lobby + +import com.palantir.blueprintjs.IconName +import com.palantir.blueprintjs.Intent +import com.palantir.blueprintjs.bpIcon +import kotlinx.css.Align +import kotlinx.css.Display +import kotlinx.css.FlexDirection +import kotlinx.css.alignItems +import kotlinx.css.display +import kotlinx.css.flexDirection +import kotlinx.css.margin +import kotlinx.css.opacity +import org.luxons.sevenwonders.model.api.PlayerDTO +import react.RBuilder +import react.ReactElement +import react.dom.* +import styled.css +import styled.styledDiv +import styled.styledH5 + +fun RBuilder.radialPlayerList(players: List<PlayerDTO>, currentPlayer: PlayerDTO): ReactElement { + val playerItemBuilders = players + .growTo(targetSize = 3) + .withUserFirst(currentPlayer) + .map { p -> p.elementBuilder(p?.username == currentPlayer.username) } + + val tableImgBuilder: ElementBuilder = { roundTableImg() } + + return radialList( + itemBuilders = playerItemBuilders, + centerElementBuilder = tableImgBuilder, + itemWidth = 120, + itemHeight = 100, + options = RadialConfig( + radius = 175, + firstItemAngleDegrees = 180 // self at the bottom + ) + ) +} + +private fun RBuilder.roundTableImg(): ReactElement = img { + attrs { + src = "images/round-table.png" + alt = "Round table" + width = "200" + height = "200" + } +} + +private fun List<PlayerDTO?>.withUserFirst(me: PlayerDTO): List<PlayerDTO?> { + val nonUsersBeginning = takeWhile { it?.username != me.username } + val userToEnd = subList(nonUsersBeginning.size, size) + return userToEnd + nonUsersBeginning +} + +private fun <T> List<T>.growTo(targetSize: Int): List<T?> { + if (size >= targetSize) return this + return this + List(targetSize - size) { null } +} + +private fun PlayerDTO?.elementBuilder(isMe: Boolean): ElementBuilder { + if (this == null) { + return { playerPlaceholder() } + } else { + return { playerItem(this@elementBuilder, isMe) } + } +} + +private fun RBuilder.playerItem(player: PlayerDTO, isMe: Boolean): ReactElement = styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.column + alignItems = Align.center + } + val title = if (player.isGameOwner) "Game owner" else null + userIcon(isMe = isMe, isOwner = player.isGameOwner, title = title) + styledH5 { + css { + margin = "0" + } + +player.displayName + } +} + +private fun RBuilder.playerPlaceholder(): ReactElement = styledDiv { + css { + display = Display.flex + flexDirection = FlexDirection.column + alignItems = Align.center + opacity = 0.3 + } + userIcon(isMe = false, isOwner = false, title = "Waiting for player...") + styledH5 { + css { + margin = "0" + } + +"?" + } +} + +private fun RBuilder.userIcon(isMe: Boolean, isOwner: Boolean, title: String?): ReactElement { + val iconName: IconName = if (isOwner) "badge" else "user" + val intent: Intent = if (isMe) Intent.WARNING else Intent.NONE + return bpIcon(name = iconName, intent = intent, size = 50, title = title) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt new file mode 100644 index 00000000..3e3de561 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt @@ -0,0 +1,26 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.cards.PreparedCard +import redux.RAction + +data class SetCurrentPlayerAction(val player: ConnectedPlayer): RAction + +data class UpdateGameListAction(val games: List<LobbyDTO>): RAction + +data class UpdateLobbyAction(val lobby: LobbyDTO): RAction + +data class EnterLobbyAction(val lobby: LobbyDTO): RAction + +data class EnterGameAction(val lobby: LobbyDTO, val turnInfo: PlayerTurnInfo): RAction + +data class TurnInfoEvent(val turnInfo: PlayerTurnInfo): RAction + +data class PreparedMoveEvent(val move: PlayerMove): RAction + +data class PreparedCardEvent(val card: PreparedCard): RAction + +data class PlayerReadyEvent(val username: String): RAction diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt new file mode 100644 index 00000000..836f5b4e --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt @@ -0,0 +1,23 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.model.CustomizableSettings +import org.luxons.sevenwonders.model.PlayerMove +import redux.RAction + +data class RequestChooseName(val playerName: String): RAction + +data class RequestCreateGame(val gameName: String): RAction + +data class RequestJoinGame(val gameId: Long): RAction + +data class RequestReorderPlayers(val orderedPlayers: List<String>): RAction + +data class RequestUpdateSettings(val settings: CustomizableSettings): RAction + +class RequestStartGame: RAction + +class RequestSayReady: RAction + +data class RequestPrepareMove(val move: PlayerMove): RAction + +class RequestUnprepareMove: RAction diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt new file mode 100644 index 00000000..c21f6deb --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt @@ -0,0 +1,84 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.model.PlayerMove +import org.luxons.sevenwonders.model.PlayerTurnInfo +import org.luxons.sevenwonders.model.api.ConnectedPlayer +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.model.api.PlayerDTO +import org.luxons.sevenwonders.model.api.State +import org.luxons.sevenwonders.model.cards.CardBack +import org.luxons.sevenwonders.model.cards.HandCard +import redux.RAction + +data class SwState( + val connectedPlayer: ConnectedPlayer? = null, + // they must be by ID to support updates to a sublist + val gamesById: Map<Long, LobbyDTO> = emptyMap(), + val currentLobby: LobbyDTO? = null, + val gameState: GameState? = null +) { + val currentPlayer: PlayerDTO? = (gameState?.players ?: currentLobby?.players)?.first { + it.username == connectedPlayer?.username + } + val games: List<LobbyDTO> = gamesById.values.toList() +} + +data class GameState( + val id: Long, + val players: List<PlayerDTO>, + val turnInfo: PlayerTurnInfo?, + val preparedCardsByUsername: Map<String, CardBack?> = emptyMap(), + val currentPreparedMove: PlayerMove? = null +) { + val currentPreparedCard: HandCard? + get() = turnInfo?.hand?.firstOrNull { it.name == currentPreparedMove?.cardName } +} + +fun rootReducer(state: SwState, action: RAction): SwState = state.copy( + gamesById = gamesReducer(state.gamesById, action), + connectedPlayer = currentPlayerReducer(state.connectedPlayer, action), + currentLobby = currentLobbyReducer(state.currentLobby, action), + gameState = gameStateReducer(state.gameState, action) +) + +private fun gamesReducer(games: Map<Long, LobbyDTO>, action: RAction): Map<Long, LobbyDTO> = when (action) { + is UpdateGameListAction -> (games + action.games.associateBy { it.id }).filterValues { it.state != State.FINISHED } + else -> games +} + +private fun currentPlayerReducer(currentPlayer: ConnectedPlayer?, action: RAction): ConnectedPlayer? = when (action) { + is SetCurrentPlayerAction -> action.player + else -> currentPlayer +} + +private fun currentLobbyReducer(currentLobby: LobbyDTO?, action: RAction): LobbyDTO? = when (action) { + is EnterLobbyAction -> action.lobby + is UpdateLobbyAction -> action.lobby + is PlayerReadyEvent -> currentLobby?.let { l -> + l.copy(players = l.players.map { p -> + if (p.username == action.username) p.copy(isReady = true) else p + }) + } + else -> currentLobby +} + +private fun gameStateReducer(gameState: GameState?, action: RAction): GameState? = when (action) { + is EnterGameAction -> GameState( + id = action.lobby.id, + players = action.lobby.players, + turnInfo = action.turnInfo + ) + is PreparedMoveEvent -> gameState?.copy(currentPreparedMove = action.move) + is RequestUnprepareMove -> gameState?.copy(currentPreparedMove = null) + is PreparedCardEvent -> gameState?.copy( + preparedCardsByUsername = gameState.preparedCardsByUsername + (action.card.player.username to action.card.cardBack) + ) + is PlayerReadyEvent -> gameState?.copy(players = gameState.players.map { p -> + if (p.username == action.username) p.copy(isReady = true) else p + }) + is TurnInfoEvent -> gameState?.copy( + players = gameState.players.map { p -> p.copy(isReady = false) }, + turnInfo = action.turnInfo + ) + else -> gameState +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt new file mode 100644 index 00000000..6f50a627 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt @@ -0,0 +1,29 @@ +package org.luxons.sevenwonders.ui.redux + +import org.luxons.sevenwonders.ui.redux.sagas.SagaManager +import redux.RAction +import redux.Store +import redux.WrapperAction +import redux.applyMiddleware +import redux.compose +import redux.createStore +import redux.rEnhancer +import kotlin.browser.window + +val INITIAL_STATE = SwState() + +private fun <A, T1, R> composeWithDevTools(function1: (T1) -> R, function2: (A) -> T1): (A) -> R { + val reduxDevtoolsExtensionCompose = window.asDynamic().__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + if (reduxDevtoolsExtensionCompose == undefined) { + return compose(function1, function2) + } + return reduxDevtoolsExtensionCompose(function1, function2) as Function1<A, R> +} + +fun configureStore( + sagaManager: SagaManager<SwState, RAction, WrapperAction>, + initialState: SwState = INITIAL_STATE +): Store<SwState, RAction, WrapperAction> { + val sagaEnhancer = applyMiddleware(sagaManager.createMiddleware()) + return createStore(::rootReducer, initialState, composeWithDevTools(sagaEnhancer, rEnhancer())) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt new file mode 100644 index 00000000..67ac5304 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt @@ -0,0 +1,39 @@ +package org.luxons.sevenwonders.ui.redux + +import react.RClass +import react.RComponent +import react.RProps +import react.RState +import react.invoke +import react.redux.rConnect +import redux.RAction +import redux.WrapperAction +import kotlin.reflect.KClass + +inline fun <reified DP : RProps> connectDispatch( + clazz: KClass<out RComponent<DP, out RState>>, + noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit +): RClass<RProps> { + val connect = rConnect(mapDispatchToProps = mapDispatchToProps) + return connect.invoke(clazz.js.unsafeCast<RClass<DP>>()) +} + +inline fun <reified SP : RProps> connectState( + clazz: KClass<out RComponent<SP, out RState>>, + noinline mapStateToProps: SP.(SwState, RProps) -> Unit +): RClass<RProps> { + val connect = rConnect(mapStateToProps = mapStateToProps) + return connect.invoke(clazz.js.unsafeCast<RClass<SP>>()) +} + +inline fun <reified SP : RProps, reified DP : RProps, reified P : RProps> connectStateAndDispatch( + clazz: KClass<out RComponent<P, out RState>>, + noinline mapStateToProps: SP.(SwState, RProps) -> Unit, + noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit +): RClass<RProps> { + val connect = rConnect<SwState, RAction, WrapperAction, RProps, SP, DP, P>( + mapStateToProps = mapStateToProps, + mapDispatchToProps = mapDispatchToProps + ) + return connect.invoke(clazz.js.unsafeCast<RClass<P>>()) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt new file mode 100644 index 00000000..7806bc98 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt @@ -0,0 +1,50 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.hildan.krossbow.stomp.StompSubscription +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.ui.redux.EnterLobbyAction +import org.luxons.sevenwonders.ui.redux.RequestCreateGame +import org.luxons.sevenwonders.ui.redux.RequestJoinGame +import org.luxons.sevenwonders.ui.redux.UpdateGameListAction +import org.luxons.sevenwonders.ui.router.Navigate +import org.luxons.sevenwonders.ui.router.Route +import org.luxons.sevenwonders.ui.utils.awaitFirst + +suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) { + GameBrowserSaga(session, this).run() +} + +private class GameBrowserSaga( + private val session: SevenWondersSession, + private val sagaContext: SwSagaContext +) { + suspend fun run() { + coroutineScope { + val gamesSubscription = session.watchGames() + launch { dispatchGameUpdates(gamesSubscription) } + val lobby = awaitCreateOrJoinGame() + gamesSubscription.unsubscribe() + sagaContext.dispatch(EnterLobbyAction(lobby)) + sagaContext.dispatch(Navigate(Route.LOBBY)) + } + } + + private suspend fun dispatchGameUpdates(gamesSubscription: StompSubscription<List<LobbyDTO>>) { + sagaContext.dispatchAll(gamesSubscription.messages) { UpdateGameListAction(it) } + } + + private suspend fun awaitCreateOrJoinGame(): LobbyDTO = awaitFirst(this::awaitCreateGame, this::awaitJoinGame) + + private suspend fun awaitCreateGame(): LobbyDTO { + val action = sagaContext.next<RequestCreateGame>() + return session.createGame(action.gameName) + } + + private suspend fun awaitJoinGame(): LobbyDTO { + val action = sagaContext.next<RequestJoinGame>() + return session.joinGame(action.gameId) + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt new file mode 100644 index 00000000..a9c2ca2c --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt @@ -0,0 +1,36 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.ui.redux.PlayerReadyEvent +import org.luxons.sevenwonders.ui.redux.PreparedCardEvent +import org.luxons.sevenwonders.ui.redux.PreparedMoveEvent +import org.luxons.sevenwonders.ui.redux.RequestPrepareMove +import org.luxons.sevenwonders.ui.redux.RequestSayReady +import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove +import org.luxons.sevenwonders.ui.redux.TurnInfoEvent + +suspend fun SwSagaContext.gameSaga(session: SevenWondersSession) { + val game = getState().gameState ?: error("Game saga run without a current game") + coroutineScope { + val playerReadySub = session.watchPlayerReady(game.id) + val preparedCardsSub = session.watchPreparedCards(game.id) + val turnInfoSub = session.watchTurns() + val sayReadyJob = launch { onEach<RequestSayReady> { session.sayReady() } } + val unprepareJob = launch { onEach<RequestUnprepareMove> { session.unprepareMove() } } + val prepareMoveJob = launch { + onEach<RequestPrepareMove> { + val move = session.prepareMove(it.move) + dispatch(PreparedMoveEvent(move)) + } + } + launch { dispatchAll(playerReadySub.messages) { PlayerReadyEvent(it) } } + launch { dispatchAll(preparedCardsSub.messages) { PreparedCardEvent(it) } } + launch { dispatchAll(turnInfoSub.messages) { TurnInfoEvent(it) } } + // TODO await game end + // TODO unsubscribe all subs, cancel all jobs + } + console.log("End of game saga") +} + diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt new file mode 100644 index 00000000..678276dc --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt @@ -0,0 +1,42 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.hildan.krossbow.stomp.StompSubscription +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.model.api.LobbyDTO +import org.luxons.sevenwonders.ui.redux.EnterGameAction +import org.luxons.sevenwonders.ui.redux.RequestStartGame +import org.luxons.sevenwonders.ui.redux.UpdateLobbyAction +import org.luxons.sevenwonders.ui.router.Navigate +import org.luxons.sevenwonders.ui.router.Route + +suspend fun SwSagaContext.lobbySaga(session: SevenWondersSession) { + val lobby = getState().currentLobby ?: error("Lobby saga run without a current lobby") + coroutineScope { + val lobbyUpdatesSubscription = session.watchLobbyUpdates() + launch { watchLobbyUpdates(lobbyUpdatesSubscription) } + val startGameJob = launch { awaitStartGame(session) } + + awaitGameStart(session, lobby.id) + + lobbyUpdatesSubscription.unsubscribe() + startGameJob.cancel() + dispatch(Navigate(Route.GAME)) + } +} + +private suspend fun SwSagaContext.watchLobbyUpdates(lobbyUpdatesSubscription: StompSubscription<LobbyDTO>) { + dispatchAll(lobbyUpdatesSubscription.messages) { UpdateLobbyAction(it) } +} + +private suspend fun SwSagaContext.awaitGameStart(session: SevenWondersSession, lobbyId: Long) { + val turnInfo = session.awaitGameStart(lobbyId) + val lobby = getState().currentLobby!! + dispatch(EnterGameAction(lobby, turnInfo)) +} + +private suspend fun SwSagaContext.awaitStartGame(session: SevenWondersSession) { + next<RequestStartGame>() + session.startGame() +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt new file mode 100644 index 00000000..c4a92581 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt @@ -0,0 +1,54 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.luxons.sevenwonders.client.SevenWondersClient +import org.luxons.sevenwonders.client.SevenWondersSession +import org.luxons.sevenwonders.ui.redux.RequestChooseName +import org.luxons.sevenwonders.ui.redux.SetCurrentPlayerAction +import org.luxons.sevenwonders.ui.redux.SwState +import org.luxons.sevenwonders.ui.router.Route +import org.luxons.sevenwonders.ui.router.routerSaga +import redux.RAction +import redux.WrapperAction + +typealias SwSagaContext = SagaContext<SwState, RAction, WrapperAction> + +suspend fun SwSagaContext.rootSaga() = coroutineScope { + val action = next<RequestChooseName>() + val session = SevenWondersClient().connect("localhost:8000") + console.info("Connected to Seven Wonders web socket API") + + launch { + serverErrorSaga(session) + } + yield() // ensures the error saga starts + + val player = session.chooseName(action.playerName) + dispatch(SetCurrentPlayerAction(player)) + + routerSaga(Route.GAME_BROWSER) { + when (it) { + Route.HOME -> homeSaga(session) + Route.LOBBY -> lobbySaga(session) + Route.GAME_BROWSER -> gameBrowserSaga(session) + Route.GAME -> gameSaga(session) + } + } +} + +private suspend fun serverErrorSaga(session: SevenWondersSession) { + val errorsSub = session.watchErrors() + for (err in errorsSub.messages) { + // TODO use blueprintjs toaster + console.error("${err.code}: ${err.message}") + console.error(JSON.stringify(err)) + } +} + +private suspend fun SwSagaContext.homeSaga(session: SevenWondersSession) { + val action = next<RequestChooseName>() + val player = session.chooseName(action.playerName) + dispatch(SetCurrentPlayerAction(player)) +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt new file mode 100644 index 00000000..1a57708e --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt @@ -0,0 +1,137 @@ +package org.luxons.sevenwonders.ui.redux.sagas + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import redux.Middleware +import redux.MiddlewareApi +import redux.RAction + +@OptIn(ExperimentalCoroutinesApi::class) +class SagaManager<S, A : RAction, R>( + private val monitor: ((A) -> Unit)? = null +) { + private lateinit var context: SagaContext<S, A, R> + + private val actions = BroadcastChannel<A>(16) + + fun createMiddleware(): Middleware<S, A, R, A, R> = ::sagasMiddleware + + private fun sagasMiddleware(api: MiddlewareApi<S, A, R>): ((A) -> R) -> (A) -> R { + context = SagaContext(api, actions) + return { nextDispatch -> + { action -> + onActionDispatched(action) + val result = nextDispatch(action) + handleAction(action) + result + } + } + } + + private fun onActionDispatched(action: A) { + monitor?.invoke(action) + } + + private fun handleAction(action: A) { + GlobalScope.launch { actions.send(action) } + } + + fun launchSaga(coroutineScope: CoroutineScope, saga: suspend SagaContext<S, A, R>.() -> Unit): Job { + checkMiddlewareApplied() + return coroutineScope.launch { + context.saga() + } + } + + suspend fun runSaga(saga: suspend SagaContext<S, A, R>.() -> Unit) { + checkMiddlewareApplied() + context.saga() + } + + private fun checkMiddlewareApplied() { + check(::context.isInitialized) { + "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware" + } + } +} + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class SagaContext<S, A : RAction, R>( + private val reduxApi: MiddlewareApi<S, A, R>, + private val actions: BroadcastChannel<A> +) { + /** + * Gets the current redux state. + */ + fun getState(): S = reduxApi.getState() + + /** + * Dispatches the given redux [action]. + */ + fun dispatch(action: A) { + reduxApi.dispatch(action) + } + + /** + * Dispatches an action given by [createAction] for each message received in [channel]. + */ + suspend fun <T> dispatchAll(channel: ReceiveChannel<T>, createAction: (T) -> A) { + for (msg in channel) { + reduxApi.dispatch(createAction(msg)) + } + } + + /** + * Executes [handle] on every action dispatched. This runs forever until the current coroutine is cancelled. + */ + suspend fun onEach(handle: suspend SagaContext<S, A, R>.(A) -> Unit) { + val channel = actions.openSubscription() + try { + for (a in channel) { + handle(a) + } + } finally { + channel.cancel() + } + } + + /** + * Executes [handle] on every action dispatched of the type [T]. This runs forever until the current coroutine is + * cancelled. + */ + suspend inline fun <reified T : A> onEach( + crossinline handle: suspend SagaContext<S, A, R>.(T) -> Unit + ) = onEach { + if (it is T) { + handle(it) + } + } + + /** + * Suspends until the next action matching the given [predicate] is dispatched, and returns that action. + */ + suspend fun next(predicate: (A) -> Boolean): A { + val channel = actions.openSubscription() + try { + for (a in channel) { + if (predicate(a)) { + return a + } + } + } finally { + channel.cancel() + } + error("Actions channel closed before receiving a matching action") + } + + /** + * Suspends until the next action of type [T] is dispatched, and returns that action. + */ + suspend inline fun <reified T : A> next(): T = next { it is T } as T +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt new file mode 100644 index 00000000..19e8bd94 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt @@ -0,0 +1,32 @@ +package org.luxons.sevenwonders.ui.router + +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.luxons.sevenwonders.ui.redux.sagas.SwSagaContext +import redux.RAction +import kotlin.browser.window + +enum class Route(val path: String) { + HOME("/"), + GAME_BROWSER("/games"), + LOBBY("/lobby"), + GAME("/game"), +} + +data class Navigate(val route: Route): RAction + +suspend fun SwSagaContext.routerSaga( + startRoute: Route, + runRouteSaga: suspend SwSagaContext.(Route) -> Unit +) { + coroutineScope { + window.location.hash = startRoute.path + var currentSaga: Job = launch { runRouteSaga(startRoute) } + onEach<Navigate> { + currentSaga.cancel() + window.location.hash = it.route.path + currentSaga = launch { runRouteSaga(it.route) } + } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt new file mode 100644 index 00000000..600f08d3 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt @@ -0,0 +1,15 @@ +package org.luxons.sevenwonders.ui.utils + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.selects.select + +// Cannot inline or it crashes for some reason +suspend fun <R> awaitFirst(f1: suspend () -> R, f2: suspend () -> R): R = coroutineScope { + val deferred1 = async { f1() } + val deferred2 = async { f2() } + select<R> { + deferred1.onAwait { deferred2.cancel(); it } + deferred2.onAwait { deferred1.cancel(); it } + } +} diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt new file mode 100644 index 00000000..07b3f2b5 --- /dev/null +++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt @@ -0,0 +1,16 @@ +package org.luxons.sevenwonders.ui.utils + +import kotlinx.html.SPAN +import kotlinx.html.attributesMapOf +import react.RBuilder +import react.ReactElement +import react.dom.* + +/** + * Creates a ReactElement without appending it (so that is can be passed around). + */ +fun createElement(block: RBuilder.() -> ReactElement): ReactElement { + return RDOMBuilder { SPAN(attributesMapOf("class", null), it) } + .apply { block() } + .create() +} |