Creating a dialog box with React for a Phaser game

Written in October 6, 2021 - 🕒 4 min. read

Phaser works by rendering pixels inside a canvas element in the DOM, and it’s great, but one of the great features of web development is the power of the DOM and CSS to create great UI elements, and you can’t do that with Phaser alone.

Dialog Box
Dialog Box

I recently needed a dialog box for my game, and by Googling “rpg dialog box react” I found a great tutorial by Cameron Tatz on how to create a dialog box using React and react-spring, and that’s exactly what I was looking for.

The original tutorial was using an old version of react-spring, so I updated it and added some other features, which I will show in this tutorial.

The code

This dialog box will use react-spring to show the message letter by letter. If you press ENTER, it will finish the current message animation and show it in full and if you press ENTER again it will move to the next message if there is one.

The two main changes in the code for the Message.jsx component are that the useTransition API changed to receive just the items and the config object, instead of the items, a mapping function, and the config object.

I also added the onRest property to the useTransition config object to check if the message animation is over. onRest is called after every letter animation, so I need to check for the current letter index.

If the prop forceShowFullMessage is sent, then I will simply show the full message, without any animation.

import React, { useMemo } from 'react';
import { animated, useTransition } from 'react-spring';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles((theme) => ({
    dialogMessage: () => ({
        fontSize: '12px',
        textTransform: 'uppercase',
    }),
}));

const Message = ({
    message = [],
    trail = 35,
    onMessageEnded = () => {},
    forceShowFullMessage = false,
}) => {
    const classes = useStyles();
    const items = useMemo(
        () => message.trim().split('').map((letter, index) => ({
            item: letter,
            key: index,
        })),
        [message]
    );

    const transitions = useTransition(items, {
        trail,
        from: { display: 'none' },
        enter: { display: '' },
        onRest: (status, controller, item) => {
            if (item.key === items.length - 1) {
                onMessageEnded();
            }
        },
    });

    return (
        <div className={classes.dialogMessage}>
            {forceShowFullMessage && (
                <span>{message}</span>
            )}
            {!forceShowFullMessage && transitions((styles, { item, key }) => (
                <animated.span key={key} style={styles}>
                    {item}
                </animated.span>
            ))}
        </div>
    );
};

export default Message;

For the DialogBox.jsx component, I’m using a background image very similar to the one in the original tutorial.

Dialog Box background
Dialog Box background

This file handles the keyboard interactions for the dialog, so if the player presses ENTER and the message animation is still playing, then it finishes the current animation by setting forceShowFullMessage to true. If the player presses ENTER and the message animation is already finished, then it goes to the next message.

Besides forceShowFullMessage, I also get screenWidth and screenHeight as props, so I can scale up or down the size of the box depending on the size of the Phaser game.

import React, { useCallback, useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';

// Images
import dialogBorderBox from '../images/dialog_borderbox.png';

// Components
import Message from './Message';

const useStyles = makeStyles((theme) => ({
    dialogWindow: ({ screenWidth, screenHeight }) => {
        const messageBoxHeight = Math.ceil(screenHeight / 3.5);

        return {
            imageRendering: 'pixelated',
            textTransform: 'uppercase',
            backgroundColor: '#e2b27e',
            border: 'solid',
            borderImage: `url("${dialogBorderBox}") 6 / 12px 12px 12px 12px stretch`,
            padding: '16px',
            position: 'absolute',
            top: `${Math.ceil(screenHeight - (messageBoxHeight + (messageBoxHeight * 0.1)))}px`,
            width: `${Math.ceil(screenWidth * 0.8)}px`,
            left: '50%',
            transform: 'translate(-50%, 0%)',
            minHeight: `${messageBoxHeight}px`,
        };
    },
    dialogTitle: () => ({
        fontSize: '16px',
        marginBottom: '12px',
        fontWeight: 'bold',
    }),
    dialogFooter: () => ({
        fontSize: '16px',
        cursor: 'pointer',
        textAlign: 'end',
        position: 'absolute',
        right: '12px',
        bottom: '12px',
    }),
}));

const DialogBox = ({
    messages,
    characterName,
    onDialogEnded,
    screenWidth,
    screenHeight,
}) => {
    const [currentMessage, setCurrentMessage] = useState(0);
    const [messageEnded, setMessageEnded] = useState(false);
    const [forceShowFullMessage, setForceShowFullMessage] = useState(false);
    const classes = useStyles({
        screenWidth,
        screenHeight,
    });

    const handleClick = useCallback(() => {
        if (messageEnded) {
            setMessageEnded(false);
            setForceShowFullMessage(false);
            if (currentMessage < messages.length - 1) {
                setCurrentMessage(currentMessage + 1);
            } else {
                setCurrentMessage(0);
                onDialogEnded();
            }
        } else {
            setMessageEnded(true);
            setForceShowFullMessage(true);
        }
    }, [currentMessage, messageEnded, messages.length, onDialogEnded]);

    useEffect(() => {
        const handleKeyPressed = (e) => {
            if (['Enter', 'Space', 'Escape'].includes(e.code)) {
                handleClick();
            }
        };
        window.addEventListener('keydown', handleKeyPressed);

        return () => window.removeEventListener('keydown', handleKeyPressed);
    }, [handleClick]);

    return (
        <div className={classes.dialogWindow}>
            <div className={classes.dialogTitle}>
                {characterName}
            </div>
            <Message
                action={messages[currentMessage].action}
                message={messages[currentMessage].message}
                key={currentMessage}
                forceShowFullMessage={forceShowFullMessage}
                onMessageEnded={() => {
                    setMessageEnded(true);
                }}
            />
            <div
                onClick={handleClick}
                className={classes.dialogFooter}
            >
                {(currentMessage === messages.length - 1 && messageEnded) ? 'Ok' : 'Next'}
            </div>
        </div>
    );
};

export default DialogBox;

And this will be the end result

Dialog Box animation

If you want to know more details about how to integrate Phaser and React, check this blog post.

Tags:


Post a comment

Comments

Flo on 11/19/21

Nice Post! One question though: How would you implement a box with two or more texts like a monologue of a npc? So if you press enter a second textbox appears? Thanks in advance