Sokoban Series: Part 4, Creating an Overlay in React

In this part of our Sokoban Series we will create an overlay for our game. The overlay contains our menu in which the user can select different levels, and a volume control. The end result looks like this:

overlay

We tried making a simple menu in Phaser, but we found that Phaser is not suited to do this in a straight-forward manner. It would take us a lot less work in simple HTML, so that’s what we did.

Enter React and material-ui. React is a JS library to create user interfaces. Material-UI provide react components that implemnet Google’s Material Design, and that simply look a lot better than I can design myself. We will not explain React in this post, that could be a whole book on its own. We focus on how you can communicate with your Phaser game from outside of Phaser.

So let’s hook React and Material-UI up to our Phaser game. Phaser itself is also just Javascript. So, we want to create a React app that renders a DOM, and in that DOM will be our Phaser game.

We create a React Component that renders a container for our game, and that creates our game in componentDidMount(). componenDidMount is the earliest point in which we are sure that our game div exists.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import * as React from 'react';
import { createGame } from '../core/game';

class GameContainer extends React.Component<{}> {
constructor(props: {}) {
super(props);
}

componentDidMount() {
createGame();
}

render() {
return (
<div id="game" />
)
}
}

export default GameContainer;

The function createGame() simply calls the constructor of our Game class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export class Game extends Phaser.Game {
constructor(conf: GameConfig) {
super(conf);
}
}

let game: Game;
export function createGame() {
game = new Game(config);
}

export function getGame(): Game {
return game;
}

That’s all we had to do. Of course we must be careful that our GameContainer is always present in our React application, if not it will be unmounted, and our game would get destroyed. To this end, we render the overlay on top of the game. The game will still be available in the DOM, it will just be hidden behind an overlay.

What are the components our overlay and menu needs:

  • A way to open the menu
  • A way to select and load a level
  • A way to control the volume

Let’s start with the volume control. We want to have a component that, when clicked opens a slider. The slider indicates the current volume, and when slidered (yes, that’s a word from now on) it changes the volume of our game. As Phaser is simple JS, we can also call function from “outside” Phaser. By accessing our game we can
simple access the sound.volume and modify it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import * as React from 'react';
import { getGame } from '../core/game';

import VolumeMuteIcon from '@material-ui/icons/VolumeMute';
import VolumeDownIcon from '@material-ui/icons/VolumeDown';
import VolumeUpIcon from '@material-ui/icons/VolumeUp';
import VolumeOffIcon from '@material-ui/icons/VolumeOff';
import IconButton from '@material-ui/core/IconButton';
import Slider from '@material-ui/lab/Slider';
import { Grid, Icon, ClickAwayListener } from '@material-ui/core';

export interface VolumeControlState {
isOpen: boolean;
volume: number;
isMuted: boolean;
}

export interface VolumeControlProps {

}

class VolumeControl extends React.Component<VolumeControlProps, VolumeControlState> {
constructor(props: VolumeControlProps) {
super(props);
this.state = {
volume: getGame().sound.volume,
isMuted: getGame().sound.mute,
isOpen: false,
}
}

render() {
const { isOpen } = this.state;
return (
<Grid container alignItems="center" style={{ minWidth: '200px' }} alignContent="flex-end">
<Grid item>
<IconButton onClick={this.iconClicked}>
{this.renderIcon()}
</IconButton>
</Grid>
{
isOpen &&
<Grid item style={{ flex: 1 }}>
{this.renderSlider()}
</Grid>
}
</Grid>
);
}

private iconClicked = (event: React.MouseEvent) => {
this.setState({
isOpen: !this.state.isOpen,
})
}

private unMute() {
this.setState({
isMuted: false,
});
getGame().sound.mute = false;
}

private renderSlider() {
const { volume } = this.state;
return (
<Slider value={volume * 100} onChange={this.volumeChange} />
)
}

private volumeChange = (event: React.ChangeEvent, value: number) => {
const actualVolume = value / 100;
this.setState({
volume: actualVolume
});
this.unMute();
getGame().sound.volume = actualVolume;
}

private renderIcon() {
const { isMuted, volume } = this.state;
if (isMuted) {
return this.renderMuted();
}
if (volume === 0) {
return this.renderVolumeOff();
}
if (volume < 0.5) {
return this.renderLowVolume();
}
return this.renderVolume();
}

private renderMuted() {
return <VolumeMuteIcon />;
}

private renderLowVolume() {
return <VolumeDownIcon />;
}

private renderVolumeOff() {
return <VolumeOffIcon />;
}

private renderVolume() {
return <VolumeUpIcon />
}
}

export default VolumeControl;

Next, let’s create a LoadLevel component that renders a thumbnail of each level, and that when clicks loads that level. All of our levels are stored in a JSON that is loaded inside Phaser. We can also access the cache of Phaser from outside. Simply get the intance of your game and call .cache, and you can get all your loaded resources.

The following component renders a grid with LevelTiles. The LevelTile itself simply renders a thumbnail of the level, the name of the level etc. You can export a thumbnail of a map in the Tiled map editor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import * as React from 'react';
import Grid from '@material-ui/core/Grid';
import LevelTile from './LevelTile';
import { getGame } from '../core/game';
import sortLevels from '../core/utils/sortLevels';

export interface LoadLevelProps {
onSelected: (level: string) => void;
}

class LoadLevel extends React.Component<LoadLevelProps, {}> {
constructor(props: LoadLevelProps) {
super(props);
}

render() {
return (
<Grid container={true} spacing={8} justify="center">
{this.renderLevelTiles()}
</Grid>
);
}

private renderLevelTiles() {
const levels = getGame().cache.json.get('levels').levels;
const sorted = sortLevels(levels);
return sorted.map((level) => {
return (
<Grid item key={level.file}>
<LevelTile level={level} onClick={this.onClick} />
</Grid>
);
})
}

private onClick = (id: string) => {
const { onSelected } = this.props;
onSelected(id);
}
}

export default LoadLevel;

We now have the main components for our overlay. Let’s create our main menu that is rendered on top of our game:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import * as React from 'react';
import Toolbar from '@material-ui/core/Toolbar';
import MenuIcon from '@material-ui/icons/Menu';
import IconButton from '@material-ui/core/IconButton';
import LoadLevel from './LoadLevel';
import Dialog from '@material-ui/core/Dialog';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import { loadLevel, getGame } from '../core/game';
import VolumeControl from './VolumeControl';
import { Grid } from '@material-ui/core';

export interface MenuState {
isOpen: boolean;
}

class MainMenu extends React.Component<{}, MenuState> {
constructor(props: {}) {
super(props);
this.state = {
isOpen: false,
}
}

render() {
return (
<div className="menu">
<Toolbar>
<Grid container alignItems="center">
<Grid item xs={2}>
{this.renderHamburger()}
</Grid>
<Grid item xs={10}>
{this.renderVolume()}
</Grid>
</Grid>
</Toolbar>
{this.renderMenu()}
</div>
)
}

private renderHamburger() {
return (
<IconButton onClick={this.toggleOpen}>
<MenuIcon />
</IconButton>
);
}

private renderVolume() {
return <VolumeControl />;
}

private renderMenu() {
const { isOpen } = this.state;
return (
<Dialog fullScreen={true} open={isOpen} onClose={this.close}>
<Toolbar>
<IconButton color="inherit" onClick={this.close} aria-label="Close">
<CloseIcon />
</IconButton>
<Typography variant="title">Select Level</Typography>
</Toolbar>
<LoadLevel onSelected={this.levelSelected} />
</Dialog>
);
}

private toggleOpen = () => {
this.setState({ isOpen: !this.state.isOpen });
}

private close = () => {
this.setState({ isOpen: false });
}

private levelSelected = (level: string) => {
this.close();
loadLevel(level);
}
}

export default MainMenu;

The final part is ensuring that our Menu is rendered above the game. We use CSS to achieve this:

1
2
3
4
5
6
.menu {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}

We enforce that our overlay is always rendered on a fixed position, and by using a higher z-index than our game it is rendered above our game.

Conclusion

In my opinion Phaser is ill suited to render complex interactive user interfaces. The DOM-tree is made specifically to create such interfaces, so I prefer to use that. In this episode we combined React with Material-UI to render a menu on top of our game. As Phaser is, in the end, simply Javascript interacting with your game from ‘the outside world’ is not that hard.

This completes our game. The only thing left to do is to publish our game. We will show you how to publish your game on CrazyGames in the next episode.