Skip to content

Instantly share code, notes, and snippets.

@zhhz
Created July 6, 2017 15:23
Show Gist options
  • Save zhhz/065c03710c77720fd9dad8716f5eacb2 to your computer and use it in GitHub Desktop.
Save zhhz/065c03710c77720fd9dad8716f5eacb2 to your computer and use it in GitHub Desktop.
Solitaire
<div id="app">Loading...</div>
/* Import { createStore, combineReducers } from 'redux' */
const { createStore, combineReducers } = Redux;
/* Import { connect, Provider } from 'react-redux' */
const { connect, Provider } = ReactRedux;
/*********
* Constatants
**********/
const consts = {
FOUNDATION: 'FOUNDATION',
TABLEAU: 'TABLEAU',
HEART: 'HEART',
CLOVER: 'CLOVER',
TILE: 'TILE',
PIKE: 'PIKE'
}
/*********
* REACT
**********/
const Game = (() => {
const UnconnectedGame = ({congratulation}) => (
<div id='container'>
<div className='header'>
<Stock />
<Foundation />
</div>
<Tableau />
<div className='congratulation'
style={{visibility: congratulation ? 'visible' : 'hidden'}}>
<h1>Congratulation, you did it!</h1>
</div>
</div>
);
const mapStateToProps = (state, ownProps) => {
return {
congratulation: (
state.foundation[0].cards.length === 13 &&
state.foundation[1].cards.length === 13 &&
state.foundation[2].cards.length === 13 &&
state.foundation[3].cards.length === 13
)
}
}
return connect(mapStateToProps)(UnconnectedGame);
})();
const Stock = (() => {
const UnconnectedStock = ({rest, flopped}) => (
<div className='stock'>
<div className='rest'>
{rest.map(card => (
<div className='cardContainer'
key={JSON.stringify({suit:card.suit,number:card.number})}>
<Card key={JSON.stringify({suit:card.suit,number:card.number})}
suit={card.suit} number={card.number}
stack={true}
turned={card.turned} locked={card.locked} last={false}/>
</div>
))}
</div>
<div className='flop'>
{flopped.map(card => (
<div className='cardContainer'
key={JSON.stringify({suit:card.suit,number:card.number})}>
<Card key={JSON.stringify({suit:card.suit,number:card.number})}
suit={card.suit} number={card.number}
stack={true}
turned={card.turned} locked={card.locked} last={card.last}/>
</div>
))}
</div>
</div>
);
const mapStateToProps = (state, ownProps) => {
return {
rest: state.stock.rest,
flopped: state.stock.flopped,
}
}
return connect(mapStateToProps)(UnconnectedStock);
})();
const Foundation = (() => {
const UnconnectedFoundation = ({piles}) => (
<div className='foundation'>
{piles.map((pile, pileIndex) => (
<div className='pile' key={pileIndex}>
{pile.cards.map(card => (
<div className='cardContainer'
key={JSON.stringify({suit:card.suit,number:card.number})}>
<Card key={JSON.stringify({suit:card.suit,number:card.number})}
suit={card.suit} number={card.number}
turned={card.turned} locked={card.locked} last={card.last}/>
</div>
))}
<DropSite key={JSON.stringify({type:consts.FOUNDATION,index:pileIndex})}
pileType={consts.FOUNDATION} pileIndex={pileIndex} visible={pile.drop}/>
</div>
))}
</div>
);
const mapStateToProps = (state, ownProps) => {
return {
piles: state.foundation
}
}
return connect(mapStateToProps)(UnconnectedFoundation);
})();
const Tableau = (() => {
const UnconnectedTableau = ({piles}) => (
<div className='tableau'>
{piles.map((pile, pileIndex) => (
<div className='pile' key={pileIndex}>
{pile.cards.map((card, index) => (
<div className='cardContainer'
key={JSON.stringify({suit:card.suit,number:card.number})}>
<Card key={JSON.stringify({suit:card.suit,number:card.number})}
suit={card.suit} number={card.number}
stack={false}
turned={card.turned} locked={card.locked} last={card.last}/>
</div>
))}
<DropSite key={JSON.stringify({type:consts.TABLEAU,index:pileIndex})}
pileType={consts.TABLEAU} pileIndex={pileIndex} visible={pile.drop}/>
</div>
))}
</div>
);
const mapStateToProps = (state, ownProps) => {
return {
piles: state.tableau
}
}
return connect(mapStateToProps)(UnconnectedTableau);
})();
/*********
* React: Card
*********/
const Card = (() => {
class UnconnectedCard extends React.Component {
constructor({suit, number, stack, turned, locked, last}) {
super({suit, number, stack, turned, locked, last});
this.state = {
rotation: turned ? 0 : -180
};
this.turn = this.turn.bind(this);
this.dragStart = this.dragStart.bind(this);
this.dragEnd = this.dragEnd.bind(this);
}
turn() {
if(this.props.stack === true) {
this.props.flop();
}else{
if(this.props.turned === true || this.props.locked) {
return;
}
let lastTime = null;
const slideBackAnimation = (time => {
let rotation = null;
if(lastTime !== null) {
const delta = (time - lastTime) * 0.4;
rotation = Math.min(0, this.state.rotation + delta);
this.setState({rotation});
}
lastTime = time;
if(rotation !== 0) requestAnimationFrame(slideBackAnimation);
}).bind(this);
requestAnimationFrame(slideBackAnimation);
}
}
dragStart(ev) {
ev.dataTransfer.setData('text/plain', JSON.stringify({
suit: this.props.suit,
number: this.props.number
}));
this.props.dragStart();
}
dragEnd(ev) {
this.props.dragEnd();
}
render() {
let suit;
switch(this.props.suit) {
case consts.HEART:
suit = <Heart />;
break;
case consts.TILE:
suit = <Tile />;
break;
case consts.CLOVER:
suit = <Clover />;
break;
case consts.PIKE:
suit = <Pike />;
break;
}
return (
<div className='card'>
<div className='cardFace frontFace'
style={{transform: `rotateY(${this.state.rotation}deg)`}}
draggable={this.props.locked === false}
onDragStart={this.dragStart}
onDragEnd={this.dragEnd}>
<div>
<h1 className={this.props.suit}>{this.props.number}</h1>
{suit}
</div>
</div>
<div className={'cardFace backFace' + (this.props.locked === false ? ' pointer' : '')}
style={{transform: `rotateY(${this.state.rotation+180}deg)`}}
onClick={this.turn}/>
</div>
);
}
}
const mapDispatchToProps = (dispatch, ownProps) => {
return {
dragStart: () => {
dispatch({
type: 'DRAG_START',
suit: ownProps.suit,
number: ownProps.number,
last: ownProps.last
});
},
dragEnd: () => {
dispatch({ type: 'DRAG_END' });
},
flop: () => {
dispatch({ type: 'FLOP' });
},
}
}
return connect(null, mapDispatchToProps)(UnconnectedCard);
})();
const Heart = ({zoom = false}) => (
<svg className='suitIcon'
width={zoom === true ? 40 : 20} height={zoom === true ? 40 : 20}
viewBox='0 0 20 20'>
<path className='heart'
d='
M 0 6
A 2.5 2.5 0 0 1 10 6
A 2.5 2.5 0 0 1 20 6
Q 16 14 10 19
Q 4 14 0 6' />
</svg>
);
const Tile = ({zoom = false}) => (
<svg className='suitIcon'
width={zoom === true ? 40 : 20} height={zoom === true ? 40 : 20}
viewBox='0 0 20 20'>
<path className='tile'
d='
M 10 0
Q 13 5 17 10
Q 13 15 10 20
Q 7 15 3 10
Q 7 5 10 0' />
</svg>
);
const Clover = ({zoom = false}) => (
<svg className='suitIcon'
width={zoom === true ? 40 : 20} height={zoom === true ? 40 : 20}
viewBox='0 0 20 20'>
<circle className='clover' cx='10' cy='5' r='4.5' />
<circle className='clover' cx='5' cy='11' r='4.5' />
<circle className='clover' cx='15' cy='11' r='4.5' />
<polygon className='clover' points='10 10, 13 20, 7 20' />
</svg>
);
const Pike = ({zoom = false}) => (
<svg className='suitIcon'
width={zoom === true ? 40 : 20} height={zoom === true ? 40 : 20}
viewBox='0 0 20 20'>
<path className='pike'
d='
M 0 12
A 2.5 2.5 0 0 0 10 12
A 2.5 2.5 0 0 0 20 12
Q 16 4 10 0
Q 4 4 0 12' />
<polygon className='pike' points='10 10, 13 20, 7 20' />
</svg>
);
/*********
* React: Drop Site
*********/
const DropSite = (() => {
class UnconnectedDropSite extends React.Component {
constructor({pileType, pileIndex, visible, drop}) {
super({pileType, pileIndex, visible, drop});
this.allowDrop = this.allowDrop.bind(this);
this.drop = this.drop.bind(this);
}
allowDrop(ev) {
ev.preventDefault();
}
drop(ev) {
ev.preventDefault();
let data = JSON.parse(ev.dataTransfer.getData("text"));
this.props.drop(data.suit, data.number);
}
render() {
return (
<div className='dropTarget'
style={{visibility: this.props.visible ? 'visible' : 'hidden'}}
onDrop={this.drop} onDragOver={this.allowDrop}>
<h1>
+
</h1>
</div>
);
}
}
const mapDispatchToProps = (dispatch, ownProps) => {
return {
drop: (suit, number) => {
dispatch({
type: 'DRAG_END'
});
dispatch({
type: 'DROP',
targetPileType: ownProps.pileType,
targetPileIndex: ownProps.pileIndex,
suit,
number
});
}
}
}
return connect(null, mapDispatchToProps)(UnconnectedDropSite);
})();
/*********
* REDUX: Store
**********/
const store = (() => {
let cards = (() => {
function deck() {
let cards = [];
for(let i = 2; i <= 14; i++) {
let number = i;
if(i === 11) number = 'J';
if(i === 12) number = 'Q';
if(i === 13) number = 'K';
if(i === 14) number = 'A';
cards.push({suit: consts.HEART, number});
cards.push({suit: consts.TILE, number});
cards.push({suit: consts.CLOVER, number});
cards.push({suit: consts.PIKE, number});
}
return cards;
}
function shuffle(a) {
for (let i = a.length; i; i--) {
let j = Math.floor(Math.random() * i);
[a[i - 1], a[j]] = [a[j], a[i - 1]];
}
}
let cards = deck();
shuffle(cards);
return cards;
})();
function pop(cards, turned = false) {
if(turned) {
return {...cards.shift(), turned: true, locked: false, last: true};
}else{
return {...cards.shift(), turned: false, locked: true, last: false};
}
}
const initialState = {
tableau: [
{drop: false, cards: [pop(cards, true)]},
{drop: false, cards: [pop(cards), pop(cards, true)]},
{drop: false, cards: [pop(cards), pop(cards), pop(cards, true)]},
{drop: false, cards: [pop(cards), pop(cards), pop(cards), pop(cards, true)]},
{drop: false, cards: [pop(cards), pop(cards), pop(cards), pop(cards), pop(cards, true)]},
{drop: false, cards: [pop(cards), pop(cards), pop(cards), pop(cards), pop(cards), pop(cards, true)]},
{drop: false, cards: [pop(cards), pop(cards), pop(cards), pop(cards), pop(cards), pop(cards), pop(cards, true)]},
],
stock: {
rest: [...cards.map(card => ({
suit: card.suit,
number: card.number,
turned: false,
locked: false,
last: false
}))],
flopped: [],
},
foundation: [
{drop: false, cards: []},
{drop: false, cards: []},
{drop: false, cards: []},
{drop: false, cards: []}
]
};
const stockReducer = (state = initialState.stock, action) => {
switch (action.type) {
case 'FLOP': {
const rest = [
...state.flopped.map(
card => ({
suit: card.suit,
number: card.number,
turned: false,
locked: false
})
),
...state.rest
];
let flopped = [];
const first = rest.pop();
const second = rest.pop();
const third = rest.pop();
if(third !== undefined) {
flopped.push(Object.assign({}, third, {turned: true, locked: true, last: false}));
}
if(second !== undefined) {
flopped.push(Object.assign({}, second, {turned: true, locked: true, last: false}));
}
if(first !== undefined) {
flopped.push(Object.assign({}, first, {turned: true, locked: false, last: true}));
}
return {
rest,
flopped
};
}
case 'DROP': {
const topFlop = state.flopped[state.flopped.length - 1];
if(topFlop !== undefined && topFlop.suit === action.suit && topFlop.number === action.number) {
// The top of the flopped cards has been moved
let flopped = [];
if(state.flopped.length > 2) {
flopped = flopped.concat(state.flopped.slice(0, -2));
}
if(state.flopped.length > 1) {
flopped.push(Object.assign(
{},
state.flopped[state.flopped.length-2],
{
locked: false,
last: true
}
));
}
return {
rest: state.rest,
flopped,
};
}
return state;
}
default: {
return state;
}
}
};
const foundationReducer = (state = initialState.foundation, action) => {
switch (action.type) {
case 'DRAG_START': {
if(action.last !== true) {
return state;
}
function match(targetCard, draggedCard) {
if(targetCard === undefined && draggedCard.number === 'A') {
return true;
}
if(targetCard === undefined) {
return false;
}
return (targetCard.suit === draggedCard.suit) &&
(
targetCard.number === 'A' && draggedCard.number === 2 ||
targetCard.number === 10 && draggedCard.number === 'J' ||
targetCard.number === 'J' && draggedCard.number === 'Q' ||
targetCard.number === 'Q' && draggedCard.number === 'K' ||
targetCard.number + 1 === draggedCard.number
)
}
let draggedCard = {suit: action.suit, number: action.number};
return state.map(
pile => ({
drop: match(pile.cards[pile.cards.length-1], draggedCard),
cards: [...pile.cards]
})
);
}
case 'DRAG_END': {
return state.map(
pile => ({
drop: false,
cards: [...pile.cards]
})
);
}
case 'DROP': {
function match(card, suit, number) {
return card !== undefined && card.suit === suit && card.number === number;
}
// Check if the moved card belonged to the foundation piles
const sourcePileIndex = state.findIndex(
pile => match(pile.cards[pile.cards.length-1], action.suit, action.number)
);
return state.map(
(pile, pileIndex) => {
// Add the card to the corresponding pile if the target is a foundation pile
if(action.targetPileType === consts.FOUNDATION && action.targetPileIndex === pileIndex) {
return {
drop: false,
cards: [
...pile.cards.map(
card => ({
suit: card.suit,
number: card.number,
turned: true,
locked: true,
last: false
})
),
{
suit: action.suit,
number: action.number,
turned: true,
locked: false,
last: true
}
]
};
}
// Remove the card from if it has been part of a foundation pile
if(sourcePileIndex !== undefined && sourcePileIndex === pileIndex) {
let cards = [];
if(pile.cards.length > 2) {
cards = cards.concat(pile.cards.slice(0,-2));
}
if(pile.cards.length > 1) {
cards.push(Object.assign( {}, pile.cards[pile.cards.length-2], {locked: false,last: true} ));
}
return {
drop: false,
cards
};
}
// Return the pile unchanged if it has not been affected by the drop
return pile;
}
);
}
default: {
return state;
}
}
};
const tableauReducer = (state = initialState.tableau, action) => {
switch (action.type) {
case 'DRAG_START': {
function match(targetCard, draggedCard) {
if(targetCard === undefined && draggedCard.number === 'K') {
return true;
}
if(targetCard === undefined) {
return false;
}
return (
(
targetCard.number === 'K' && draggedCard.number === 'Q' ||
targetCard.number === 'Q' && draggedCard.number === 'J' ||
targetCard.number === 'J' && draggedCard.number === 10 ||
targetCard.number === draggedCard.number + 1
) && (
(
(targetCard.suit === consts.HEART || targetCard.suit === consts.TILE) &&
(draggedCard.suit === consts.CLOVER || draggedCard.suit === consts.PIKE)
) ||
(
(targetCard.suit === consts.CLOVER || targetCard.suit === consts.PIKE) &&
(draggedCard.suit === consts.HEART || draggedCard.suit === consts.TILE)
)
)
);
}
let draggedCard = {suit: action.suit, number: action.number};
return state.map(
pile => ({
drop: match(pile.cards[pile.cards.length-1], draggedCard),
cards: [...pile.cards]
})
);
}
case 'DRAG_END': {
return state.map(
pile => ({
drop: false,
cards: [...pile.cards]
})
);
}
case 'DROP': {
function match(card, suit, number) {
return card !== undefined && card.suit === suit && card.number === number;
}
const [tableauWithoutMovingCards, movingCards] = state.reduce(
(accumulated, currentPile) => {
const index = currentPile.cards.findIndex(card => match(card, action.suit, action.number));
if(index !== -1) {
let cards = [];
if(index > 1) {
cards = cards.concat(currentPile.cards.slice(0, index - 1).map(
card => ({
suit: card.suit,
number: card.number,
turned: card.turned,
locked: card.locked,
last: false
})
));
}
if(index > 0) {
cards.push(Object.assign( {}, currentPile.cards[index - 1], {
locked: false,
last: true
} ));
}
accumulated[0].push({
drop: false,
cards
});
accumulated[1] = currentPile.cards.slice(index).map(
card => ({
suit: card.suit,
number: card.number,
turned: true,
locked: false,
last: card.last
})
);
}else{
accumulated[0].push(currentPile);
}
return accumulated;
},
[
[
// Tableau without moving cards
],
[
// Moving cards default (overwritten if source found in a tableau pile)
{
suit: action.suit,
number: action.number,
turned: true,
locked: false,
last: true
}
]
]
);
if(action.targetPileType === consts.TABLEAU) {
tableauWithoutMovingCards[action.targetPileIndex] = {
drop: false,
cards: tableauWithoutMovingCards[action.targetPileIndex].cards.map(
card => ({
suit: card.suit,
number: card.number,
turned: card.turned,
locked: card.locked,
last: false
})
).concat(movingCards)
};
return tableauWithoutMovingCards;
}
return tableauWithoutMovingCards;
}
default: {
return state;
}
}
};
const mainReducer = combineReducers({
stock: stockReducer,
foundation: foundationReducer,
tableau: tableauReducer
});
return createStore(mainReducer);
})();
/*********
* React DOM
*********/
ReactDOM.render(
<Provider store={store}>
<Game />
</Provider>,
document.getElementById('app')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react-dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.6.0/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/5.0.2/react-redux.min.js"></script>

Solitaire

Solitaire game using React, Redux and HTML5's drag & drop feature. Due to the nature of HTML5's drag & drop, touch devices are not supported.

Note: The top-left deck is a bit simplified and the behaviour slightly differs from the original game. Once it is clicked, it simply moves the remaining of the three drawn cards to the bottom of the deck.

#React The app uses the following react components:

Game, Stock, Foundation, Tableau

Layout components. The Game component behaves as the main container. The Stock component is the top-left section with the rest of the deck. The Foundation component is the top-right section where you can collect each suit starting with the aces. The Tableau component is the bottom component with the seven piles.

Card

Displays a single card and dispatches the DRAG_START, DRAG_END and FLOP events. Receives the following properties: card suit, number, if it belongs to the top-left stock or not (different action is performed on click if it is), if the front side or the back side is visible, is it locked (can be clicked / dragged), and whether if it is the last item in the pile. On drag event it fills the event's dataTransfer attribute with its own suit and number.

Heart, Tile, Clover, Pike

SVG components that contain the icon of each suit.

DropSite

Renders a mostly invisible drop area where a dragged card can be dropped. The visibility is handled by the redux store based on which card is currently dragged (a drop area should only be visible if the currently dragged card can be dropped to that position). Once a card is dropped on it, it interprets the event's dataTransfer attribute and dispatches a DROP event with the incoming data (which card has been dropped) and it's own position (where the card has been dropped).

#Redux There are three reducers for the three main sections of the game, the stackReducer, the foundationReducer and the tableauReducer. These reducers implement the following actions:

DRAG_START

Once a card is dragged, the possible drop sites should be set to visible. Each drop site is checked with the dragged card's suite and number and if the dragged card could be dropped to a drop site, then that DropSite component should be set to visible. In case a drop site belongs to the foundation (top-right section), it only accepts those dragged cards which are the last one in their original pile. Drop sites belonging to the tableau (bottom section) accept cards that are not the last ones in their own pile as well (if it is not the last one, the rest of the pile will be also moved).

DRAG_END

Once a card is not dragged anymore, all the drop sites must be hidden again.

DROP

Once a drop happens the dragged card has to be removed from it's original pile and has to be added to it's new position. This is not necessarily done by one reducer, if you drag a card from the stock to the tableau section, then the stockReducer's responsibility is to remove the card from it's original place, and the tableauReducer's responsibility is to add it to it's new position. In case the dragged card was not the last one in it's own pile, all the depending cards are moved as well (this is only possible within the tableau section).

FLOP

In the stock section the previously drawn cards move back to the bottom of the deck and three new cards are drawn from the top. This part slightly differs from the original behaviour of the game.

A Pen by Zhonghai Zuo on CodePen.

License.

@import url('https://fonts.googleapis.com/css?family=Lato:900');
$cardWidth: 90px;
$cardHeight: 126px;
html {
background-color: #12355B;
font-family: 'Lato', sans-serif;
}
.heart, .tile {
fill: red;
}
.clover, .pike {
fill: black;
}
#app {
position: absolute;
width: 100%;
top: 10px;
left: 0;
}
#container {
position: relative;
width: 770px;
margin: 0 auto;
}
.congratulation {
color: white;
font-size: 2em;
text-align: center;
}
.header {
margin: 10px;
height: $cardHeight;
}
.stock, .rest, .flop, .foundation {
vertical-align: top;
display: inline-block;
}
.rest {
margin-right: 95px;
.cardContainer {
position: relative;
width: 3px;
perspective: 500px;
display: inline-block;
card {
display: inline-block;
}
}
}
.flop {
.cardContainer {
position: relative;
width: 30px;
perspective: 500px;
display: inline-block;
card {
display: inline-block;
}
}
}
.foundation {
position: absolute;
right: 10px;
.pile {
box-sizing: border-box;
display: inline-block;
margin-left: 20px;
.cardContainer {
position: absolute;
}
}
}
.tableau {
white-space: nowrap;
.pile {
display: inline-block;
margin: 10px;
vertical-align: top;
width: $cardWidth;
.cardContainer {
position: relative;
height: 40px;
perspective: 500px;
}
}
}
.cardFace, .dropTarget, .foundation > .pile {
width: $cardWidth;
height: $cardHeight;
border-radius: 10px;
box-shadow: 0px 0px 4px 0px rgba(196,196,196,1);
}
.cardFace {
background-color: white;
padding: 10px;
box-sizing: border-box;
backface-visibility: hidden;
&.frontFace[draggable="true"] {
cursor: grab;
}
&.backFace {
background-color: lightgray;
position: absolute;
top: 0;
}
&.pointer {
cursor: pointer;
}
h1 {
font-size: 25px;
display: inline-block;
user-select: none;
margin: 0;
margin-right: 5px;
&.HEART, &.TILE {
color: red;
}
&.CLOVER, &.PIKE {
color: black;
}
}
.suitIcon {
padding-left: 5px;
}
}
.dropTarget {
position: absolute;
background-color: rgba(white, 0.3);
h1 {
user-select: none;
margin: 0;
color: #11937C;
font-size: 25px;
text-align: center;
line-height: $cardHeight;
vertical-align: middle;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment