DEV Community

Cover image for Card Game UI/UX 🐺 Gwent: Witcher + Demo 🍿
Roden
Roden

Posted on • Updated on

Card Game UI/UX 🐺 Gwent: Witcher + Demo 🍿

☢️ Warning !!! ☣️ The site is very heavily loaded, be careful. ☣️

Full Portfolio Demo (!!! 15 SECONDS PRELOAD)


GitHub logo Kerthin / gwentReact-templateSait

The template represents the appearance of the 'deck Constructor' menu from the Gwent card game.

Kerthin logo

Build Status Version Version
Size Version

Description

In this template, you can see a large list of different cards, each of which has its own personal characteristics (provisions, stats, fraction, bonus, name, description, image). Thanks to internal filters, you can manage this list of cards.

  • To launch the app
    • download the repository;
    • log in to the downloaded repository using the command line or terminal;
    • enter the command line 'npm run start' or 'serve-s build' and go to the address specified in the terminal.
  • Note that the project was made using the CREATE REACT APP.
  • Also, the app is very loaded (due to the abundance of media files), so it is not recommended for zapupsku on mobile devices, although the site is adapted to small tablet screens.
  • I would like to note that this is just a test project that does not claim anything. I made it solely out of personal interest.

  • Demo

Use technology.

The following…




Recomend use 0.5x

Introduction

About 2 years ago, I decided to download a card game called Gwent on my iPad. I am a big fan of the Witcher series of games and it was interesting for me to try a separate project from the Witcher universe. Initially, I didn't have high hopes for the game, but after I played the game, I was extremely impressed. In addition to the interesting gameplay, I couldn't help but pay attention to the visual part of the game. It is executed perfectly.

And first of all, it is the way the playing cards are made that attracts attention. In addition to various bronze and gold borders with a beautiful font and icons, the map is surprised by its animated part.

Alt Text
Alt Text
Alt Text

Well, what can I say, and the gameplay itself in the game looks great
Alt Text
Alt Text

By the way, the game used to look like this.
Alt Text

And after all those pleasant emotions that I experienced, I decided to try to implement part of the interface of this game. Namely, the section with the selection of cards for creating a deck.

Concenpt

The site is a collection of cards, each of which belongs to a different faction (there are only 7 of them). Each of the cards has its own special characteristics, such as: provisions, cost, card type, color, name and faction. Based on this data, we need to create appropriate filters that will help us find the necessary maps.

All information about the maps will be stored in our array.

Design

As for the interface, I decided to recreate it close to the original.

Game UI:
Alt Text

As for the cards themselves, I decided to find the sources of all the elements that are used in the styling of cards.

1. Each faction should have its own color style.

Neutral
Alt Text

Monsters
Alt Text

Nilfgaard
Alt Text

Northern
Alt Text

Scoiatael
Alt Text

Skellige
Alt Text

Syndicate
Alt Text

2. You also need to indicate the cost of the card. To do this, we need special icons.
Alt Text

3. We also need to divide the cards into types.
Alt Text

4. Font.
Alt Text

5. And it remains only to choose bronze and gold borders for .
Alt Text

Create Card

To create a correct card, we need to take into account all the data specified in the array. The fraction (specified as a class), border (specified as a class), the path to the video (specified in src), cost (specified as a class), the name of the card and its description.

The videos for the cards themselves are stored in the build/video folder, where they are all divided into sections with provisions.
Alt Text

class Card extends React.Component {
    render() {
        const product = this.props.product;

        var provPath = String(product.prov.match(/\d+/));

        var factionClass = product.faction;
        var borderClass = product.border;
        var videoPath = `img/video/${product.type}/${provPath}/${product.src}`;

        var prov = <p className="provisionBg__number">{parseInt(product.prov.match(/\d+/))}</p>;

        var cost;
        if (product.usual) {
            cost = 'usual';
        } else if (product.rare) {
            cost = 'rare';
        } else if (product.epic) {
            cost = 'epic';
        } else {
            cost = 'legendary';
        }

        var strengthType;
        var provBlock = <div className="provisionBg">{prov}</div>;

        if (product.type === 'special') {
            strengthType = <div className="strengthBg__special"></div>;
        } else if (product.type === 'artifact') {
            strengthType = <div className="strengthBg__artifact"></div>;
        } else if (product.type === 'strategy') {
            videoPath = `img/video/${product.type}/${product.src}`;
            strengthType = <div className="strengthBg__strategy"></div>;
            provBlock = null;
        } else {
            strengthType = <p className="strengthBg__number">{product.strength}</p>;
        }

        var nameTooltip = <h1 className="tooltip__title">{product.name}</h1>;

        return (
            <div className={`card _${factionClass} _${borderClass}`}>
                <video src={videoPath} poster="img/poster/cursedScroll.mp4" preload="true" autoPlay loop muted></video>

                <div className="borderBg"></div>
                <div className={`strengthBg _${cost}`}>{strengthType}</div>
                {provBlock}

                <div className="tooltip">
                    <div className="tooltip__titleWrap">{nameTooltip}</div>
                    <div className="tooltip__infoWrap">
                        <div className="tooltip__info">{product.tooltip}</div>
                    </div>
                </div>
            </div>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

SCSS

.card {
    position: relative;
    float: left;
    width: 134px;
    height: 194px;
    margin-left: 30px;
    margin-top: 30px;
    cursor: pointer;
    transition: all .3s;

    video {
        position: absolute;
        width: 100%;
        height: 100%;
    }

    &._bronze .borderBg {
        background-image: url('../../../../img/cardElements/border/borderBronze.png');
    }
    &._gold .borderBg {
        background-image: url('../../../../img/cardElements/border/borderGold.png');
    }

    @mixin cardFactionElements($faction) {
        &._#{$faction} {
            .strengthBg {
                background-image: url('../../../../img/cardElements/#{$faction}/strength.png');
            }
            .provisionBg {
                background-image: url('../../../../img/cardElements/#{$faction}/provision.png');
            }
            .tooltip__titleWrap {
                background-image: url('../../../../img/cardElements/#{$faction}/tooltip.jpg');
            }
        }
    }

    @include cardFactionElements('neutral');
    @include cardFactionElements('monsters');
    @include cardFactionElements('nilfgaard');
    @include cardFactionElements('northern');
    @include cardFactionElements('scoiatael');
    @include cardFactionElements('skellige');
    @include cardFactionElements('syndicate');

    &:hover {
        transform: scale(1.1);
        z-index: 5;

        .tooltip {
            display: block;
        }
        .tooltip {
            &:hover {
                display: none;
            }
        }
    }

    &:last-child {
        margin-bottom: 20px;
    }

    &:nth-child(6n-1),
    &:nth-child(6n) {
        .tooltip {
            left: -177px;
        }
    }
}
.borderBg {
    position: absolute;
    width: 100%;
    height: 100%;
}
.strengthBg {
    position: absolute;
    width: 43px;
    height: 43px;
    @extend %containBg;

    &__special,
    &__artifact,
    &__strategy,
    &__number {
        @extend %containBg;
    }

    @mixin cardTypeIcon($type, $mTop, $mLeft, $width, $height) {
        &__#{$type} {
            margin-top: #{$mTop}px;
            margin-left: #{$mLeft}px;
            width: #{$width}px;
            height: #{$height}px;
            background-image: url('../../../../img/cardElements/type/#{$type}.png');
        }
    }

    @include cardTypeIcon('special', '5', '4', '32', '32');
    @include cardTypeIcon('artifact', '7', '6', '30', '30');
    @include cardTypeIcon('strategy', '8', '8', '25', '25');

    &__number {
        font-family: Gwent;
        font-size: 28px;
        color: $paletteStrength;
        text-align: center;
        line-height: 1.6;
        text-shadow: rgb(0, 0, 0) 0px 2px 5px;
    }

    @mixin cardGemIcon($cost) {
        &._#{$cost} {
            &:after {
                background-image: url('../../../../img/cardElements/gem/#{$cost}.png');
            }
        }
    }

    @include cardGemIcon('usual');
    @include cardGemIcon('rare');
    @include cardGemIcon('epic');
    @include cardGemIcon('legendary');

    &:after {
        content: '';
        position: absolute;
        top: 7px;
        left: 6px;
        width: 7px;
        height: 7px;
        @extend %containBg;
    }
}

.provisionBg {
    position: absolute;
    width: 26px;
    height: 54px;
    bottom: 4px;
    right: 4px;
    @extend %containBg;

    &__number {
        font-family: Gwent;
        font-size: 24px;
        color: $paletteSandPrimary;
        text-align: center;
        line-height: 3.3;
        text-shadow: rgb(0, 0, 0) 0px 2px 5px;
    }
}
Enter fullscreen mode Exit fullscreen mode

Result

{ name: "Draug",
    prov: "pp13",
    type: "unit",
    strength: "7",
    faction: "northern",
    border: "gold",
    legendary: true,
    src: "draug.mp4",
    tooltip: <p>{deploy}: Transform all allied Human units in this row into Kaedweni Revenants, without changing their power.</p>
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Full View
Alt Text

Tooltip

It remains only to add the description output to each map when hovering.

.tooltip {
    position: absolute;
    top: 2px;
    right: -177px;
    width: 177px;
    display: none;
    transition: all .3s;

    &__titleWrap {
        position: relative;
        padding-top: 3px;
        width: 100%;
        height: 56px;
        background-size: 100%;
        background-repeat: no-repeat;
    }
    &__title {
        margin-top: 10px;
        font-family: Gwent;
        font-size: 17px;
        text-transform: uppercase;
        color: $paletteParagraph;
        text-align: center;
        text-shadow: rgb(0, 0, 0) 0px 2px 5px;
    }

    &__infoWrap {
        background-image: url(../../../../img/cardElements/tooltipInfo/bottom.png), url(../../../../img/cardElements/tooltipInfo/center.jpg);
        background-size: 100%;
        border-bottom-left-radius: 8px;
        border-bottom-right-radius: 8px;
        padding-bottom: 10px;
        background-repeat: no-repeat, repeat-y;
        background-position: center bottom;
    }

    &__info {
        margin-bottom: 6px;
        position: relative;
        word-break: break-word;
        padding: 8px 12px 14px;

        p {
            font-size: 14px;
            color: $paletteTooltioParagraph;
            line-height: 16px;
            font-family: sans-serif;
        }
        span {
            font-weight: 600;
            color: $paletteTooltioKeyword;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Result
Alt Text

Since the hint is displayed to the right of the card, it is very important that the hint is displayed on the left of the rightmost cards.

&:nth-child(6n-1),
&:nth-child(6n) {
    .tooltip {
       left: -177px;
    }
}
Enter fullscreen mode Exit fullscreen mode

Filters

The appearance of the filters is made in the form of a side menu with columns of sliding blocks.

Alt Text

function BorderFilterWrap(props) {
    return(
        <label className="sideFilterWrap">
            <input type="checkbox" className="openFilter" />
            <FrontBlock frontFilterIcon={'color'} frontFilterName={'Color'} />
            <div className="popupFilter">
                <RadioButton value={""} radioName={"border"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"gold"} radioName={"border"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"bronze"} radioName={"border"} checked={props.checked} onChange={props.onChange} />
            </div>
        </label>
    );
}
Enter fullscreen mode Exit fullscreen mode
function CostFilterWrap(props) {
    return(
        <label className="sideFilterWrap">
            <input type="checkbox" className="openFilter" />
            <FrontBlock frontFilterIcon={'cost'} frontFilterName={'Cost'} />
            <div className="popupFilter">
                <CheckboxButton value={"usual"} checked={props.checkedUsual} onChange={props.onChangeUsual} />
                <CheckboxButton value={"rare"} checked={props.checkedRare} onChange={props.onChangeRare} />
                <CheckboxButton value={"epic"} checked={props.checkedEpic} onChange={props.onChangeEpic} />
                <CheckboxButton value={"legendary"} checked={props.checkedLegendary} onChange={props.onChangeLegendary} />
            </div>
        </label>
    );
}
Enter fullscreen mode Exit fullscreen mode
function FactionFilterWrap(props) {
    return(
        <label className="sideFilterWrap">
            <input type="checkbox" className="openFilter" />
            <FrontBlock frontFilterIcon={'faction'} frontFilterName={'Fraction'} />
            <div className="popupFilter">
                <RadioButton value={""} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"neutral"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"monsters"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"nilfgaard"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"northern"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"scoiatael"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"skellige"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"syndicate"} radioName={"faction"} checked={props.checked} onChange={props.onChange} />
            </div>
        </label>
    );
}
Enter fullscreen mode Exit fullscreen mode
function TypeFilterWrap(props) {
    return(
        <label className="sideFilterWrap">
            <input type="checkbox" className="openFilter" />
            <FrontBlock frontFilterIcon={'type'} frontFilterName={'Type'} />
            <div className="popupFilter">
                <RadioButton value={""} radioName={"type"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"unit"} radioName={"type"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"special"} radioName={"type"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"artifact"} radioName={"type"} checked={props.checked} onChange={props.onChange} />
                <RadioButton value={"strategy"} radioName={"type"} checked={props.checked} onChange={props.onChange} />
            </div>
        </label>
    );
}
Enter fullscreen mode Exit fullscreen mode

SCSS

@import "../../../../variables/StyleVariables/StyleVariables.scss";

.sideFilterWrap {
    position: relative;
    display: block;
    margin-bottom: 15px;
    width: 95%;
}
.openFilter {
    display: none;

    &:checked {
        + .frontBlock {
            .frontBlock__toogle {
                transform: rotate(180deg);
            }
        }
    }
    &:checked {
        + .frontBlock {
            + .popupFilter {
                max-height: 1000px;
            }
        }
    }
}

.popupFilter {
    position: relative;
    display: block;
    width: 100%;
    max-height: 0;
    background-color: $palettePopup;
    overflow: hidden;
    transition: max-height .5s;
}

.filterBlock__buttonBg {
    @mixin sideFilterIcon($family, $nameIcon, $extension) {
        &._#{$nameIcon} {
            background-image: url('../../../../img/icons/#{$family}/#{$nameIcon}.#{$extension}');
        }
    }

    @include sideFilterIcon('faction', 'neutral', 'png');
    @include sideFilterIcon('faction', 'monsters', 'png');
    @include sideFilterIcon('faction', 'nilfgaard', 'png');
    @include sideFilterIcon('faction', 'northern', 'png');
    @include sideFilterIcon('faction', 'scoiatael', 'png');
    @include sideFilterIcon('faction', 'skellige', 'png');
    @include sideFilterIcon('faction', 'syndicate', 'png');
}
Enter fullscreen mode Exit fullscreen mode

Working Filter
Alt Text

Provision Filter

Now we need to make a filter based on the provisions of the card.

Provisions is the number indicated at the bottom of the card, it indicates the amount of free space needed to put it in the deck.

Alt Text

function ProvisionFilter(props) {
    return(
        <div className="filterBlock__provWrap clearfix">
            <RadioButtonProv value={'p4'} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'0-4'} />
            <RadioButtonProv value={"p5"} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'5'} />
            <RadioButtonProv value={"p6"} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'6'} />
            <RadioButtonProv value={"p7"} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'7'} />
            <RadioButtonProv value={"p8"} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'8'} />
            <RadioButtonProv value={"p9"} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'9'} />
            <RadioButtonProv value={"p10"} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'10'} />
            <RadioButtonProv value={'pp1'} radioName={"provision"} checked={props.checked} onChange={props.onChange} number={'11+'} />
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Result
Alt Text

Text Filter

Well, the last thing left for us is to search by the name of the card.

function TextFilter(props) {
    return(
        <div className="filterBlock__textFilterWrap">
            <input
                    type="text"
                    placeholder="SEARCH"
                    value={props.value}
                    onChange={props.onChange}
                    className="filterBlock__textFilter"
                />
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Responsive

Resize Browser Window
Alt Text

Tablet
Alt Text

The End

Well, that's it. Thank you for your time. I want to note right away that I created this project 2 years ago. This is one of my first projects that I did with React. So take this article as a story about how I tried to copy the interface of the game and nothing more.

I can advise you to subscribe to my Twitter, I also post my works there.

Goodbye
Alt Text

Top comments (10)

Collapse
 
varunpappu profile image
Varun Subramanian

Amazing. I don’t have the vision for UI or UX but this is amazing.

Collapse
 
kerthin profile image
Roden

Thanks

Collapse
 
gitpaulo profile image
Paulo Santos

As a GWENT player myself, I love this. As a software dev, I love this even more.

Collapse
 
kerthin profile image
Roden

Thank you for your high appreciation! 😊

Collapse
 
ryannerd profile image
Ryan Jentzsch

Wow! This is some impressive stuff. Thanks for sharing.

Collapse
 
zuluana profile image
Oranda

Awesome!! Very cool post

Collapse
 
kerthin profile image
Roden

Thanks

Collapse
 
codewithyaku profile image
CodeWithYaku

Good work , you are passionate.

Collapse
 
ponikar profile image
Ponikar

Your UI design idea is awesome.

Collapse
 
kerthin profile image
Roden

Thanks