Drag-and-drop with Aurelia
The Aurelia gitter channel has over 1,700 members at the time of this writing! It's a good place to hear about the cool things people are doing with Aurelia. A lot of people are building interesting projects that combine Aurelia with drag-and-drop libraries like sortable. I don't have a lot of experience with drag-and-drop so I decided to try my hand at building a classic mouse driven app: Klondike Solitaire.
Native drag-and-drop
My first thought was to build the app with Aurelia and native HTML5 drag-and-drop. I quickly found out that this is a disaster. It's so bad it made Peter-Paul Koch of QuirksMode fame give up on the feature altogether:
After spending about a day and a half in testing I am forced to conclude that the HTML5 drag and drop module is not just a disaster, it’s a fucking disaster.
The module should be removed from the HTML5 specification straight away, and conforming browsers should disable it at their earliest opportunity pending a complete rewrite from the ground up.
Web developers MUST NOT (in the sense of RFC 2119) use HTML 5 drag and drop. They should use old-school scripts instead.
After failing miserably myself, googling and reading his post I decided I'd heed Koch's warning and use a libarary.
Enter Dragula...
Dragula is the new kid on the block in terms of drag-and-drop. It has a clean, flexible API and no dependencies so I decided to try it out.
Modeling a single-player card game
The domain model for solitaire consists of 5 parts: Card, Pile, Table, Dealer and the game (Klondike).
Here's the class definition for Card:
export class Card {
suit;
rank;
up = false;
next = null;
}
Not much surprising here- a Card has a suit
, rank
and an up
(face-up) property. The key feature of this model is the next
property. A Card is a recursive/linked-list model. Each card has a reference to the next card above it in the pile.
Here's the Pile class:
class Pile {
type;
canDrag;
canDrop;
next = null;
getLastCard(orSelf, nextToLast) {
... return the last card in the pile (or next-to-last card) ...
}
}
Piles have a type property that identifies whether the pile is the deck pile, the waste pile, one of the four foundation piles or the seven tableau piles. Piles also have a couple boolean properties representing whether cards can be dragged on or off from them. The next
property representing the bottom card in the pile.
The Table model is just a collection of piles with some convenience methods used by the game logic to move cards and locate piles by card.
export class Table {
piles = [];
addPile(pile) {
this.piles.push(pile);
}
getPile(card) {
... find the pile containing the specified card ...
}
moveCard(card, toCardOrPile, reveal) {
... move the card to the specified pile ...
}
}
The Dealer class is used when constructing the game model. It's only job is to create, shuffle and deal cards in the deck.
export class Dealer {
cards;
shuffle() {
... create a deck of cards and shuffle it ...
}
deal(pile, count) {
... deal the specified count of cards into the specified pile ...
}
}
Tying it all together
So far we've described the model for the game but we haven't defined how these objects are displayed or interact. There's a few parts to this, I'll lay them out here:
- Elements: We need html elements and css representing the cards, piles and the table.
- Drag-and-drop: We need to manage which DOM elements are drag containers and drop containers.
- Game events: Cards can be clicked, double-clicked and dropped. Empty piles can be clicked.
- Game rules: Klondike Solitaire has rules. The game logic needs to react to game events and do things like move cards in the logical model or check for the "win status".
Elements
If you haven't clicked on the game link yet, here's what it looks like:
There are only three views in the game: the Card element, Pile element and the Klondike game view. The seven column layout of the game is achieved using custom grid css generated by Zurb Foundation.
The game view is just a series of Pile elements layed out with grid css:
<template>
<require from='./pile-element'></require>
<div class="container">
<div class="row first">
<pile pile.bind="deck" class="column one"></pile>
<pile pile.bind="waste" class="column one"></pile>
<pile pile.bind="foundation[0]" class="column one offset-by-one"></pile>
<pile pile.bind="foundation[1]" class="column one"></pile>
<pile pile.bind="foundation[2]" class="column one"></pile>
<pile pile.bind="foundation[3]" class="column one"></pile>
</div>
<div class="row">
<pile pile.bind="tableau[0]" class="column one"></pile>
<pile pile.bind="tableau[1]" class="column one"></pile>
<pile pile.bind="tableau[2]" class="column one"></pile>
<pile pile.bind="tableau[3]" class="column one"></pile>
<pile pile.bind="tableau[4]" class="column one"></pile>
<pile pile.bind="tableau[5]" class="column one"></pile>
<pile pile.bind="tableau[6]" class="column one"></pile>
</div>
<div class="win" if.bind="win">You Win!</div>
</div>
</template>
Card and Pile elements: I won't list the code and markup for these elements here but I do want to call out a key part of the design...
The Card element uses a recursive template, matching the recursive nature of the Card model. By nesting each Card element within the Card element beneath it, implementing grabbing and moving a stack of cards becomes a non-issue because moving a card automatically carries the cards above it.
Drag-and-drop
We're using Aurelia which is MV* SPA framework. Personally, I tend to think in terms of MVVM. How do we implement drag-and-drop using an external library that cares nothing about bindings, models, templates and will happily move elements all over the DOM without concern for the fact that the Aurelia templating system is tracking them? To avoid potential problems I always cancel the drop. Instead of letting the Dragula mutate the DOM I publish a CardDroppedEvent(card, target)
, signaling the users intent. From there the Klondike game logic handles validating the drop according to the game rules and moving the card within the model, allowing the Aurelia templating/binding system to update the view the normal way.
All the Dragula related code is here. It primarily concerns determining which elements are allowed to be picked up, which elements can be dropped into and translating Dragula drop events to game events.
Game events
To decouple the game logic from the game elements, I'm using Aurelia's event-aggregator module. The idea is to have the custom elements publish game events which the game subscribes to and handles by updating the model accordingly. There are four events:
CardClicked(card)
CardDoubleClicked(card)
CardDropped(card, target)
PlaceholderClickedEvent(pile)
(fired when an empty pile is clicked)
The other event related abstraction is the CardClickPublisher
. Card elements don't use the event aggregator to publish clicks directly, instead they go through the CardClickPublisher
which handles discerning clicks from double-clicks.
Game rules
Everything covered so far has been generic stuff related to single player card games. All the Klondike-specific logic is in the Klondike.html view and Klondike.js view-model. The logic in the view-model falls into two categories:
- Constructing the game model. This involves defining the deck, waste, foundation and tableau piles and using the Dealer to populate the piles.
- Reacting to game events. This is just a matter of deciding if a click/double-click/drop should move something in the game, and checking whether the game has been "won".
Wrapping up
Hopefully this has been helpful in terms of using drag-and-drop with Aurelia. Give the game a try. If you win you'll get a little encouragement from the winningest man in America.