├── .gitignore ├── README.md ├── bsconfig.json ├── package.json ├── public ├── icons.woff └── index.html ├── src ├── Main.re ├── _icons.scss ├── _mixins.scss ├── board.re ├── components │ ├── About.re │ ├── Bead.re │ ├── BoardBase.re │ ├── BoardView.re │ ├── Column.re │ ├── Game.re │ ├── Si.re │ ├── Sidebar.re │ ├── _About.scss │ ├── _Bead.scss │ ├── _Board.scss │ ├── _BoardBase.scss │ ├── _Column.scss │ └── _sidebar.scss ├── engine.re ├── main.scss └── util.re └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .bsb.lock 3 | .merlin 4 | npm-debug.log 5 | /node_modules/ 6 | /lib/ 7 | /public/bundle.js 8 | /public/main.css 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Si 2 | 3 | Play with it [here](https://scottcheng.github.io/si-reason/)! 4 | 5 | ## Development 6 | 7 | The project is built with [Reason](https://reasonml.github.io/) and [ReasonReact](https://reasonml.github.io/reason-react/). 8 | 9 | To build and run this project: 10 | 11 | ``` 12 | npm install 13 | npm start 14 | 15 | # In another tab 16 | cd public && http-server 17 | ``` 18 | 19 | ## License 20 | 21 | MIT 22 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | /* This is the BuckleScript configuration file. Note that this is a comment; 2 | BuckleScript comes with a JSON parser that supports comments and trailing 3 | comma. If this screws with your editor highlighting, please tell us by filing 4 | an issue! */ 5 | { 6 | "name" : "si-reason", 7 | "reason" : {"react-jsx" : 2}, 8 | "bsc-flags": ["-bs-super-errors"], 9 | "bs-dependencies": ["reason-react"], 10 | "refmt": 3, 11 | "sources": [ 12 | { 13 | "dir": "src", 14 | "subdirs": ["components"] 15 | } 16 | ], 17 | "namespace": true, 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "si-reason", 3 | "private": true, 4 | "version": "0.0.1", 5 | "description": "", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/scottcheng/si-reason.git" 9 | }, 10 | "scripts": { 11 | "build-css": "node-sass-chokidar src/ -o public/", 12 | "watch-css": "npm run build-css && node-sass-chokidar src/ -o public/ --watch --recursive", 13 | "build-re": "bsb -make-world", 14 | "start-re": "bsb -make-world -w", 15 | "clean-re": "bsb -clean-world", 16 | "webpack": "webpack -w", 17 | "build": "npm run build-scss && npm run build-re", 18 | "start": "npm-run-all -p watch-css start-re webpack", 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "author": "Scott Cheng (http://scottcheng.com/)", 22 | "bugs": { 23 | "url": "https://github.com/scottcheng/si-reason/issues" 24 | }, 25 | "homepage": "https://github.com/scottcheng/si-reason#readme", 26 | "main": "index.js", 27 | "keywords": [], 28 | "dependencies": { 29 | "react": "^15.4.2", 30 | "react-dom": "^15.4.2", 31 | "reason-react": ">=0.2.1" 32 | }, 33 | "devDependencies": { 34 | "bs-platform": "^2.0.0", 35 | "node-sass-chokidar": "0.0.3", 36 | "npm-run-all": "^4.1.1", 37 | "webpack": "^1.14.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scottcheng/si-reason/99cd49893d7d80ea9ac686c5ed4787973f1e0e15/public/icons.woff -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Si 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Main.re: -------------------------------------------------------------------------------- 1 | ReactDOMRe.renderToElementWithId(, "root"); 2 | -------------------------------------------------------------------------------- /src/_icons.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icon'; 3 | src:url('./icons.woff') format('woff'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | [class^="icon-"], [class*=" icon-"] { 9 | font-family: 'icon'; 10 | speak: none; 11 | font-style: normal; 12 | font-weight: normal; 13 | font-variant: normal; 14 | text-transform: none; 15 | line-height: 1; 16 | 17 | /* Better Font Rendering =========== */ 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | } 21 | 22 | .icon-user:before { 23 | content: "\f007"; 24 | } 25 | .icon-close:before { 26 | content: "\f00d"; 27 | } 28 | .icon-rotate-right:before { 29 | content: "\f01e"; 30 | } 31 | .icon-rotate-left:before { 32 | content: "\f0e2"; 33 | } 34 | -------------------------------------------------------------------------------- /src/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin makeShadow($borderRadius) { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 50%; 6 | height: 100%; 7 | border-radius: $borderRadius; 8 | background: rgba(#000, .07); 9 | } 10 | -------------------------------------------------------------------------------- /src/board.re: -------------------------------------------------------------------------------- 1 | let numRows = 4; 2 | 3 | let iList = [0, 1, 2, 3]; 4 | 5 | let ijList = 6 | iList |> List.map((i) => iList |> List.map((j) => (i, j))) |> List.flatten; 7 | 8 | let ijkList = 9 | ijList 10 | |> List.map(((i, j)) => iList |> List.map((k) => (i, j, k))) 11 | |> List.flatten; 12 | 13 | type player = 14 | | P1 15 | | P2; 16 | 17 | type state = array(array(array(option(player)))); 18 | 19 | let emptyState: state = 20 | None |> Array.make(numRows) |> Array.make(numRows) |> Array.make(numRows); 21 | 22 | let winner = (state) => { 23 | /* Pair of coordinates at two ends of 4 consecutive places */ 24 | let allPairs = 25 | ijkList 26 | |> List.map( 27 | ((z1, x1, y1)) => 28 | ijkList |> List.map(((z2, x2, y2)) => ((z1, x1, y1), (z2, x2, y2))) 29 | ) 30 | |> List.flatten 31 | |> List.filter( 32 | (((z1, x1, y1), (z2, x2, y2))) => 33 | (z1 != z2 || x1 != x2 || y1 != y2) 34 | && [z1 - z2, x1 - x2, y1 - y2] 35 | |> List.for_all((d) => d mod (numRows - 1) == 0) 36 | ); 37 | let interpolate = (a, b, i) => (b - a) / (numRows - 1) * i + a; 38 | let allPositionsForPair = (((z1, x1, y1), (z2, x2, y2))) => 39 | iList 40 | |> List.map( 41 | (i) => ( 42 | interpolate(z1, z2, i), 43 | interpolate(x1, x2, i), 44 | interpolate(y1, y2, i) 45 | ) 46 | ); 47 | let checkWinner = (p, ((z1, x1, y1), (z2, x2, y2))) => 48 | allPositionsForPair(((z1, x1, y1), (z2, x2, y2))) 49 | |> List.map(((z, x, y)) => state[z][x][y]) 50 | |> List.for_all((el) => el == Some(p)); 51 | let isWinner = (p) => allPairs |> List.exists(checkWinner(p)); 52 | let findWinnerPair = (p) => allPairs |> List.find(checkWinner(p)); 53 | switch (isWinner(P1), isWinner(P2)) { 54 | | (true, true) => None 55 | | (true, false) => Some((P1, allPositionsForPair(findWinnerPair(P1)))) 56 | | (false, true) => Some((P2, allPositionsForPair(findWinnerPair(P2)))) 57 | | (false, false) => None 58 | } 59 | }; 60 | 61 | let isFull = (state) => 62 | ijList |> List.for_all(((x, y)) => state[numRows - 1][x][y] != None); 63 | 64 | let isEnd = (state) => winner(state) != None || isFull(state); 65 | 66 | let isValidMove = ((x, y), state) => 67 | ! isEnd(state) && state[numRows - 1][x][y] == None; 68 | 69 | let move = ((x, y), player, state) => 70 | isValidMove((x, y), state) ? 71 | { 72 | let putPieceOnLayer = ((x, y), player, layer) => { 73 | let newLayer = layer |> Array.map((row) => Array.copy(row)); 74 | newLayer[x][y] = Some(player); 75 | newLayer 76 | }; 77 | let (newState, _) = 78 | Array.fold_left( 79 | ((curState, hasPlaced), layer) => 80 | ! hasPlaced && layer[x][y] == None ? 81 | ( 82 | Array.append( 83 | curState, 84 | [|putPieceOnLayer((x, y), player, layer)|] 85 | ), 86 | true 87 | ) : 88 | (Array.append(curState, [|layer|]), hasPlaced), 89 | ([||], false), 90 | state 91 | ); 92 | newState 93 | } : 94 | state; 95 | -------------------------------------------------------------------------------- /src/components/About.re: -------------------------------------------------------------------------------- 1 | let se = ReasonReact.stringToElement; 2 | 3 | let component = ReasonReact.statelessComponent("About"); 4 | 5 | let make = (~close, _children) => { 6 | ...component, 7 | render: (_self) => 8 |
9 |
11 |

12 | (se("Si is an online version of ")) 13 | 14 | (se("Score Four")) 15 | 16 | (se(", or 3D ")) 17 | 18 | (se("Connect Four")) 19 | 20 | (se(". ")) 21 | (se({js|The name, Si, means four in Chinese — 四 / 肆.|js})) 22 |

23 |

24 | ( 25 | se( 26 | "The rule is simple: two players take turns to put pieces on " 27 | ++ "one of the 16 columns, with the objective of connecting 4 " 28 | ++ "of your pieces in a straight line in any direction." 29 | ) 30 | ) 31 |

32 |

33 | ( 34 | se( 35 | "In its current form, two players play on the same computer. " 36 | ++ "Future versions may introduce remote games, and AI players." 37 | ) 38 | ) 39 |

40 |

41 | 42 | (se("Made by ")) 43 | 44 | (se("Scott Cheng")) 45 | 46 | (se(". ")) 47 | (se("Written in ")) 48 | 49 | (se("Reason")) 50 | 51 | (se(" and open sourced on ")) 52 | 53 | (se("GitHub")) 54 | 55 | (se(".")) 56 | 57 |

58 |
59 |
60 | }; 61 | -------------------------------------------------------------------------------- /src/components/Bead.re: -------------------------------------------------------------------------------- 1 | let component = ReasonReact.statelessComponent("Bead"); 2 | 3 | let cap =
; 4 | 5 | let make = (~player, ~winning, _children) => { 6 | ...component, 7 | render: (_self) => 8 | switch player { 9 | | Board.P1 => 10 |
18 | cap 19 |
20 | | Board.P2 => 21 |
29 | cap 30 |
31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/BoardBase.re: -------------------------------------------------------------------------------- 1 | let component = ReasonReact.statelessComponent("BoardBase"); 2 | 3 | let columnKey = (x, y) => {j|$x-$y|j}; 4 | 5 | let markerId = (x, y) => {j|BoardBase-marker-$x-$y|j}; 6 | 7 | let boardPadding = 0.125; 8 | 9 | let position = (x) => 10 | string_of_float( 11 | ( 12 | boardPadding 13 | +. (1.0 -. boardPadding *. 2.0) 14 | /. float_of_int(Board.numRows - 1) 15 | *. float_of_int(x) 16 | ) 17 | *. 100.0 18 | ) 19 | ++ "%"; 20 | 21 | let baseTransform = (rotation, winning) => 22 | "translate3d(-50%, -50%, -10000px) rotateX(62deg) rotateZ(" 23 | ++ (string_of_int(rotation + 16 + (winning ? 3600 : 0)) ++ "deg)"); 24 | 25 | let make = (~rotation, ~winning, _children) => { 26 | ...component, 27 | render: (_self) => 28 |
35 | ( 36 | Board.ijList 37 | |> List.map( 38 | ((x, y)) => 39 |
52 | ) 53 | |> Array.of_list 54 | |> ReasonReact.arrayToElement 55 | ) 56 |
57 | }; 58 | -------------------------------------------------------------------------------- /src/components/BoardView.re: -------------------------------------------------------------------------------- 1 | /* Module called BoardView to avoid conflicting filename with the board logic module */ 2 | [@bs.val] external requestAnimationFrame : (unit => unit) => unit = ""; 3 | 4 | [@bs.val] external getElementById : string => Dom.element = 5 | "document.getElementById"; 6 | 7 | let columnKey = (x, y) => {j|$x-$y|j}; 8 | 9 | let columnBaseTransform = ((x, y)) => 10 | "translate(" 11 | ++ (string_of_float(x) ++ ("px, " ++ (string_of_float(y) ++ "px)"))); 12 | 13 | let emptyColumnPositions = 14 | (0., 0.) |> Array.make_matrix(Board.numRows, Board.numRows); 15 | 16 | type state = {columnPositions: array(array((float, float)))}; 17 | 18 | type actions = 19 | | UpdateColumnPositions; 20 | 21 | let component = ReasonReact.reducerComponent("Board"); 22 | 23 | let make = (~rotation, ~board, ~move, _children) => { 24 | ...component, 25 | initialState: () => {columnPositions: emptyColumnPositions}, 26 | reducer: (action, _) => 27 | switch action { 28 | | UpdateColumnPositions => 29 | ReasonReact.Update({ 30 | columnPositions: 31 | emptyColumnPositions 32 | |> Array.mapi( 33 | (x, row) => 34 | row 35 | |> Array.mapi( 36 | (y, _) => { 37 | let rect = 38 | ReactDOMRe.domElementToObj( 39 | getElementById(BoardBase.markerId(x, y)) 40 | )##getBoundingClientRect 41 | (); 42 | (rect##left, rect##top) 43 | } 44 | ) 45 | ) 46 | }) 47 | }, 48 | didMount: ({reduce}) => { 49 | let rec onAnimationFrame = () => { 50 | reduce((_) => UpdateColumnPositions, ()); 51 | requestAnimationFrame(onAnimationFrame) 52 | }; 53 | requestAnimationFrame(onAnimationFrame); 54 | ReasonReact.NoUpdate 55 | }, 56 | render: ({state: {columnPositions}}) => 57 |
58 | true 63 | | None => false 64 | } 65 | ) 66 | /> 67 | ( 68 | Board.ijList 69 | /* Figure out order in render perspective */ 70 | |> List.sort( 71 | ((x1, y1), (x2, y2)) => { 72 | let (_, yPx1) = columnPositions[x1][y1]; 73 | let (_, yPx2) = columnPositions[x2][y2]; 74 | int_of_float(yPx1) - int_of_float(yPx2) 75 | } 76 | ) 77 | |> List.mapi((i, (x, y)) => (i, (x, y))) 78 | /* Put columns back in stable order, otherwise they rerender everytime */ 79 | |> List.sort( 80 | ((_, (x1, y1)), (_, (x2, y2))) => x1 == x2 ? y1 - y2 : x1 - x2 81 | ) 82 | |> List.map( 83 | ((i, (x, y))) => 84 |
96 | 102 | if (Board.isValidMove((x, y), board)) { 103 | move((x, y)) 104 | } 105 | ) 106 | winner=(Board.winner(board)) 107 | /> 108 |
109 | ) 110 | |> Array.of_list 111 | |> ReasonReact.arrayToElement 112 | ) 113 |
114 | }; 115 | -------------------------------------------------------------------------------- /src/components/Column.re: -------------------------------------------------------------------------------- 1 | let component = ReasonReact.statelessComponent("Column"); 2 | 3 | let make = (~board, ~x, ~y, ~tryMove, ~winner, _children) => { 4 | ...component, 5 | render: (_self) => 6 |
14 | ( 15 | board 16 | |> Array.map((layer) => layer[x][y]) 17 | |> Array.mapi( 18 | (i, el) => { 19 | let winning = 20 | switch winner { 21 | | Some((_, positions)) 22 | when positions |> List.exists((pos) => pos == (i, x, y)) => 23 | true 24 | | _ => false 25 | }; 26 | switch el { 27 | | None => ReasonReact.nullElement 28 | | Some(Board.P1) => 29 | 30 | | Some(Board.P2) => 31 | 32 | } 33 | } 34 | ) 35 | |> ReasonReact.arrayToElement 36 | ) 37 |
38 | }; 39 | -------------------------------------------------------------------------------- /src/components/Game.re: -------------------------------------------------------------------------------- 1 | type state = { 2 | rotation: int, /* In degrees */ 3 | gameState: Engine.state, 4 | showAbout: bool 5 | }; 6 | 7 | type actions = 8 | | Rotate(int) 9 | | Move((int, int)) 10 | | Reset 11 | | OpenAbout 12 | | CloseAbout; 13 | 14 | let component = ReasonReact.reducerComponent("Game"); 15 | 16 | let make = (_children) => { 17 | ...component, 18 | initialState: () => { 19 | rotation: 0, 20 | gameState: Engine.initialState, 21 | showAbout: false 22 | }, 23 | reducer: (action, state) => 24 | switch action { 25 | | Rotate(inc) => 26 | ReasonReact.Update({...state, rotation: state.rotation + inc}) 27 | | Move((x, y)) => 28 | ReasonReact.Update({ 29 | ...state, 30 | gameState: Engine.move((x, y), state.gameState) 31 | }) 32 | | Reset => 33 | ReasonReact.Update({ 34 | rotation: 0, 35 | gameState: Engine.initialState, 36 | showAbout: false 37 | }) 38 | | OpenAbout => ReasonReact.Update({...state, showAbout: true}) 39 | | CloseAbout => ReasonReact.Update({...state, showAbout: false}) 40 | }, 41 | render: ({state: {rotation, gameState, showAbout}, reduce}) => 42 |
43 | Rotate((-90)))) 46 | rotateCounterClockwise=(reduce((_) => Rotate(90))) 47 | reset=(reduce((_) => Reset)) 48 | openAbout=(reduce((_) => OpenAbout)) 49 | /> 50 | Move((x, y)))) 54 | /> 55 | ( 56 | showAbout ? 57 | CloseAbout)) /> : ReasonReact.nullElement 58 | ) 59 |
60 | }; 61 | -------------------------------------------------------------------------------- /src/components/Si.re: -------------------------------------------------------------------------------- 1 | let component = ReasonReact.statelessComponent("Si"); 2 | 3 | let make = (_children) => { 4 | ...component, 5 | render: (_self) =>
6 | }; 7 | -------------------------------------------------------------------------------- /src/components/Sidebar.re: -------------------------------------------------------------------------------- 1 | let se = ReasonReact.stringToElement; 2 | 3 | let component = ReasonReact.statelessComponent("Sidebar"); 4 | 5 | let make = 6 | ( 7 | ~gameState: Engine.state, 8 | ~rotateClockwise, 9 | ~rotateCounterClockwise, 10 | ~reset, 11 | ~openAbout, 12 | _children 13 | ) => { 14 | ...component, 15 | render: (_self) => 16 |
17 |
(se("SI"))
18 |
19 | ( 20 | [(Board.P1, "Player 1"), (Board.P2, "Player 2")] 21 | |> List.mapi( 22 | (i, (player, name)) => 23 |
true 34 | | _ => false 35 | } 36 | ) 37 | ]) 38 | )> 39 |
"Sidebar-playerBead Sidebar-playerBead--p1" 43 | | P2 => "Sidebar-playerBead Sidebar-playerBead--p2" 44 | } 45 | ) 46 | /> 47 | (se(name)) 48 |
49 | ) 50 | |> Array.of_list 51 | |> ReasonReact.arrayToElement 52 | ) 53 |
54 |
55 |
59 |
63 |
64 |
65 |
66 | 67 | (se("New game")) 68 | 69 |
70 |
71 | 72 | (se("About")) 73 | 74 |
75 |
76 |
77 | }; 78 | -------------------------------------------------------------------------------- /src/components/_About.scss: -------------------------------------------------------------------------------- 1 | .About { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background: rgba(#fff, .25); 8 | z-index: 100; 9 | 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | 15 | .About-content { 16 | background: rgba(#fff, .9); 17 | width: 600px; 18 | padding: 50px; 19 | line-height: 1.5; 20 | 21 | a { 22 | color: $gray-light; 23 | transition: color .15s; 24 | &:hover { 25 | color: $gray; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/_Bead.scss: -------------------------------------------------------------------------------- 1 | $Bead-size: 50px; 2 | $Bead-gap: -3px; 3 | 4 | @import "./Column"; 5 | 6 | @keyframes bounce { 7 | 0% { 8 | transform: scale(.75); 9 | } 10 | 50% { 11 | transform: scale(1.1); 12 | } 13 | 100% { 14 | transform: scale(1); 15 | } 16 | } 17 | 18 | @keyframes shine { 19 | 0% { 20 | box-shadow: 0 0 $color-winning; 21 | } 22 | 50% { 23 | box-shadow: 0 0 0 5px $color-winning; 24 | } 25 | 100% { 26 | box-shadow: 0 0 $color-winning; 27 | } 28 | } 29 | 30 | .Bead { 31 | position: relative; 32 | width: $Bead-size; 33 | height: $Bead-size; 34 | left: -($Bead-size / 2); 35 | margin-top: $Bead-gap; 36 | border-radius: 50%; 37 | animation: bounce .2s; 38 | 39 | &--p1 { 40 | background: $color-bead-1; 41 | } 42 | &--p2 { 43 | background: $color-bead-2; 44 | } 45 | &--winning { 46 | animation: shine 1s infinite; 47 | } 48 | } 49 | 50 | .Bead::before { 51 | content: ""; 52 | @include makeShadow(#{$Bead-size} 0 0 #{$Bead-size} / #{$Bead-size}); 53 | } 54 | 55 | .Bead-cap { 56 | position: absolute; 57 | bottom: - $Column-capHeight; 58 | left: ($Bead-size - $Column-width) / 2; 59 | width: $Column-width; 60 | height: $Column-capHeight * 2; 61 | border-radius: #{$Column-width / 2} / #{$Column-capHeight}; 62 | background: $Column-background; 63 | transition: background .25s; 64 | 65 | .Column--canMove:hover & { 66 | background: $Column-backgroundHover; 67 | } 68 | } 69 | 70 | .Bead-cap::before { 71 | content: ""; 72 | @include makeShadow(#{$Column-width / 2} 0 0 #{$Column-width / 2} / #{$Column-capHeight}); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/_Board.scss: -------------------------------------------------------------------------------- 1 | .Board-columnContainer { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/_BoardBase.scss: -------------------------------------------------------------------------------- 1 | .BoardBase { 2 | position: fixed; 3 | width: 500px; 4 | height: 500px; 5 | top: 60%; 6 | left: 55%; 7 | background: #f8f8f8; 8 | transition: transform .25s; 9 | 10 | &--winning { 11 | transition: transform 100s linear; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/_Column.scss: -------------------------------------------------------------------------------- 1 | $Column-width: 16px; 2 | $Column-height: $Bead-size * 4 + $Bead-gap * 3 + 5; 3 | $Column-capHeight: 5px; 4 | $Column-background: #e0e0e0; 5 | $Column-backgroundHover: #92e2b0; 6 | 7 | .Column { 8 | position: absolute; 9 | transform: rotate(180deg); // so beads go bottom up 10 | left: (-$Column-width / 2); 11 | bottom: 0; 12 | height: $Column-height; 13 | width: $Column-width; 14 | padding: (-$Bead-gap) ($Column-width / 2) 0; 15 | border-radius: #{$Column-width / 2} #{$Column-width / 2} 0 0 / #{$Column-capHeight}; 16 | background: $Column-background; 17 | transition: background .25s; 18 | } 19 | 20 | // Shadow 21 | .Column::before { 22 | content: ""; 23 | @include makeShadow(#{$Column-width / 2} 0 0 0 / #{$Column-capHeight}); 24 | } 25 | 26 | // Cap 27 | .Column::after { 28 | content: ""; 29 | position: absolute; 30 | bottom: - $Column-capHeight; 31 | left: 0; 32 | width: 100%; 33 | height: $Column-capHeight * 2; 34 | border-radius: #{$Column-width / 2} / #{$Column-capHeight}; 35 | background: #eee; 36 | transition: background .25s; 37 | } 38 | 39 | .Column--canMove { 40 | cursor: pointer; 41 | &:hover { 42 | background: $Column-backgroundHover; 43 | 44 | &::after { 45 | background: #d6f0e0; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/_sidebar.scss: -------------------------------------------------------------------------------- 1 | .Sidebar { 2 | position: fixed; 3 | left: 5%; 4 | top: 50%; 5 | transform: translateY(-50%); 6 | } 7 | 8 | .Sidebar-title { 9 | font-size: 36px; 10 | } 11 | 12 | .Sidebar-section { 13 | margin-top: 60px; 14 | } 15 | 16 | .Sidebar-subSection { 17 | margin-top: 15px; 18 | } 19 | 20 | .Sidebar-player { 21 | opacity: .5; 22 | transition: opacity .25s; 23 | 24 | &--active { 25 | opacity: 1; 26 | } 27 | 28 | &--winner { 29 | opacity: 1; 30 | color: $color-winning; 31 | &::after { 32 | content: " won!"; 33 | } 34 | } 35 | } 36 | 37 | .Sidebar-playerBead { 38 | display: inline-block; 39 | vertical-align: baseline; 40 | margin-right: 10px; 41 | width: 15px; 42 | height: 15px; 43 | border-radius: 50%; 44 | 45 | &--p1 { 46 | background: $color-bead-1; 47 | } 48 | &--p2 { 49 | background: $color-bead-2; 50 | } 51 | } 52 | 53 | .Sidebar-rotateBtn { 54 | display: inline-block; 55 | color: $gray; 56 | cursor: pointer; 57 | padding: 5px; 58 | margin: -5px 15px -5px -5px; 59 | transition: color .25s; 60 | &:hover { 61 | color: $gray-dark; 62 | } 63 | } 64 | 65 | .Sidebar-info { 66 | font-size: 12px; 67 | font-weight: 300; 68 | } 69 | 70 | .Sidebar-link { 71 | cursor: pointer; 72 | color: $gray-light; 73 | transition: color .25s; 74 | &:hover { 75 | color: $gray-dark; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/engine.re: -------------------------------------------------------------------------------- 1 | type state = { 2 | board: Board.state, 3 | player: Board.player 4 | }; 5 | 6 | /* TODO: Randomize player */ 7 | let initialState = {board: Board.emptyState, player: Board.P1}; 8 | 9 | let otherPlayer = (player) => 10 | switch player { 11 | | Board.P1 => Board.P2 12 | | Board.P2 => Board.P1 13 | }; 14 | 15 | let move = ((x, y), {board, player}) => 16 | Board.isValidMove((x, y), board) ? 17 | {board: Board.move((x, y), player, board), player: otherPlayer(player)} : 18 | {board, player}; 19 | -------------------------------------------------------------------------------- /src/main.scss: -------------------------------------------------------------------------------- 1 | @import "./icons"; 2 | 3 | $color-base: #f8f8f8; 4 | $color-column: #e0e0e0; 5 | $color-column-cap: #eee; 6 | $color-column-hover: #92e2b0; 7 | $color-column-cap-hover: #d6f0e0; 8 | 9 | $color-bead-1: #ff9c38; 10 | $color-bead-2: #4296f5; 11 | $color-winning: #f5c800; 12 | 13 | $gray-dark: #555; 14 | $gray: #888; 15 | $gray-light: #aaa; 16 | $yellow: #ffd61f; 17 | $yellow-dark: #f5d000; 18 | 19 | @import "./mixins"; 20 | 21 | *, *::before, *::after { 22 | box-sizing: border-box; 23 | } 24 | 25 | ::selection { 26 | background: $gray; 27 | color: #fff; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | font-family: Titillium; 33 | font-size: 16px; 34 | color: $gray-dark; 35 | } 36 | 37 | @import "./components/Sidebar"; 38 | @import "./components/BoardBase"; 39 | @import "./components/Bead"; 40 | @import "./components/Column"; 41 | @import "./components/Board"; 42 | @import "./components/About"; 43 | -------------------------------------------------------------------------------- /src/util.re: -------------------------------------------------------------------------------- 1 | let cx = (classNames: list((string, bool))) => 2 | classNames 3 | |> List.fold_left( 4 | (curClass, (c, p)) => p ? {j|$curClass $c|j} : curClass, 5 | "" 6 | ); 7 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './lib/js/src/main.js', 5 | output: { 6 | path: path.join(__dirname, "public"), 7 | filename: 'bundle.js', 8 | }, 9 | }; 10 | --------------------------------------------------------------------------------