DND: Interactive Map

Repo link | Live link (WIP)

Here are my notes while building a DND interactive map with Konva and React

Jun 23, 2023

Added icon’s for monster, party and structures. Here is the MonsterEntity example. I wanted to have custom icons for all my units. I converted royalty free SVG’s into my icon library.

MonsterEntity.tsx

import { Group, Path, Text } from 'react-konva';
import { GoblinIcon, DragonIcon, SkeletonIcon } from '../../icons';

const MonsterEntity = ({
id,
name,
x,
y,
draggable,
dragBoundFunc,
className,
onClick,
}) => {
const radius = 20;
const classShape = () => {
switch (className) {
case 'goblin':
return GoblinIcon;
case 'skeleton':
return SkeletonIcon;
case 'dragon':
return DragonIcon;
default:
return null;
}
};

return (
<Group
id={id}
x={x}
y={y}
draggable={draggable}
dragBoundFunc={dragBoundFunc}
onClick={onClick}
>
{classShape()}
</Group>
);
};

export default MonsterEntity;

CharacterPopover.tsx

I created this modal to be positioned at the dynamic value of the Icon’s location. The plan is to have game ready information for the Dungeon Master.

import Modal from 'react-modal';
import Image from 'next/image';

const customStyles = {
content: {
width: '500px',
height: '400px',
},
};

const CharacterPopover = ({ item, onClose }) => {
return (
<Modal
isOpen={!!item}
onRequestClose={onClose}
contentLabel="Item Details"
style={customStyles}
>
<h2>{item?.type} Details</h2>
{item && (
<div>
<Image height={300} width={300} src={item.src} alt={item.type} />
<p>
Position: ({item.x}, {item.y})
</p>
</div>
)}
<button onClick={onClose}>Close</button>
</Modal>
);
};

export default CharacterPopover;

KonvaMap.tsx

Here is the status of my current project. Character Popover is working on the front end (mostly) but missing information.

import React, { useState, useEffect, useCallback } from 'react';
import { Stage, Layer } from 'react-konva';

import {
generateMonsters,
generateParty,
generateStructures,
} from './map-module/utils';
import Party from './map-module/components/Party';
import Monsters from './map-module/components/Monsters';
import Structures from './map-module/components/Structures';

import CharacterPopover from './zoom-in/character-popover';

const KonvaMap = () => {
const [selectedCharacter, setSelectedCharacter] = useState(null);
const [stageScale, setStageScale] = useState(1);
const [stageX, setStageX] = useState(0);
const [stageY, setStageY] = useState(0);
const [monsters, setMonsters] = useState(generateMonsters());
const [party, setParty] = useState(generateParty());
const [structures, setStructures] = useState(generateStructures());

const handleWheel = useCallback((e) => {
e.evt.preventDefault();

const scaleBy = 1.01;
const stage = e.target.getStage();
const oldScale = stage.scaleX();

const mousePointTo = {
x: stage.getPointerPosition().x / oldScale - stage.x() / oldScale,
y: stage.getPointerPosition().y / oldScale - stage.y() / oldScale,
};

const newScale = e.evt.deltaY > 0 ? oldScale * scaleBy : oldScale / scaleBy;

setStageScale(newScale);
setStageX(
-(mousePointTo.x - stage.getPointerPosition().x / newScale) * newScale
);
setStageY(
-(mousePointTo.y - stage.getPointerPosition().y / newScale) * newScale
);
}, []);

const defaultDragBoundFunc = (pos) => pos;

const handleTouchMove = (e) => {
e.evt.preventDefault();
};

const [stageSize, setStageSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});

useEffect(() => {
const handleResize = () => {
setStageSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

const playerRadius = 20;

const dragBoundFunc = (pos) => {
const x = Math.max(
playerRadius,
Math.min(pos.x, stageSize.width - playerRadius)
);
const y = Math.max(
playerRadius,
Math.min(pos.y, stageSize.height - playerRadius)
);
return { x, y };
};

return (
<div className="border-2 border-primary m-16">
<Stage
width={stageSize.width}
height={stageSize.height}
scaleX={stageScale}
scaleY={stageScale}
x={stageX}
y={stageY}
draggable
onDragEnd={(e) => {
setStageX(e.target.x());
setStageY(e.target.y());
}}
onTouchMove={handleTouchMove}
onWheel={handleWheel}
>
<Layer>
<Party
party={party}
dragBoundFunc={defaultDragBoundFunc}
onClick={() => setSelectedCharacter('Party')}
/>
<Monsters
monsters={monsters}
dragBoundFunc={defaultDragBoundFunc}
onClick={() => setSelectedCharacter('Monster')}
/>
<Structures
structures={structures}
dragBoundFunc={defaultDragBoundFunc}
onClick={() => setSelectedCharacter('Structure')}
/>
</Layer>
</Stage>

<CharacterPopover
item={selectedCharacter}
onClose={() => setSelectedCharacter(null)}
/>
</div>
);
};

export default KonvaMap;

04.25.2023

Konva setup

I started with a complete Konva setup with React. I used the React Konva starter thathas the green stars and started tweaking it to my needs. I am using it for a DND interactive map and need to have different icons and experiences.

Few things I needed to adjust:

Setting boundaries for the draggable objects

The inital set up allowed icons to leave the Konva canvas and I didn’t want that. Here is my solution:

const dragBoundFunc = (pos) => {
const x = Math.max(playerRadius, Math.min(pos.x, stageSize.width - playerRadius));

const y = Math.max(playerRadius, Math.min(pos.y, stageSize.height - playerRadius));

return { x, y };
};

Added SVG’s with icons that represent DND classes that start in a similar location.

I wanted to add some starter classes: Fighter, Barbarian, and Wizard but I had some issues with sizing on the FighterPath. After a bunch of attempted fixes I adjusted the scale outside of the Player component and that seemed to work. Still not completely sure why the Fighter SVG was larger but that is a struggle for another day.

import { Group, Path, Text } from 'react-konva';

const FighterIcon = ();

const Player = ({ id, name, x, y, draggable, dragBoundFunc, className }) => {
const radius = 20;
const classShape = () => {
switch (className) {
case 'barbarian':
return ();
case 'wizard':
return ();
case 'fighter':
return FighterIcon;
default:
return null;
}
};

return ({
classShape()}
);
};

export default Player;
image

Pretty happy with the inital set up but there is still a long way to go!

4.30.23

Added Monster svgs and location

Here is the full Monster component

import { Group, Path, Text } from 'react-konva';

const GoblinIcon = (

);

const DragonIcon = (

);

const SkeletonIcon = (

);

const Monster = ({ id, name, x, y, draggable, dragBoundFunc, className }) => {
const radius = 20;
const classShape = () => {
switch (className) {
case 'goblin':
return GoblinIcon;
case 'skeleton':
return SkeletonIcon;
case 'dragon':
return DragonIcon;
default:
return null;
}
};

return (
{classShape()} {/* */}
);
};

export default Monster;

Here is the updated Map component

function generateMonsters() {
const monsters = ['goblin', 'skeleton', 'dragon'];
const mapWidth = window.innerWidth;
const mapHeight = window.innerHeight;
const centerX = mapWidth / 2;
const centerY = mapHeight / 2;
const exclusionRadius = 200;

function isOutsideExclusionZone(x, y) {
const distance = Math.sqrt(
Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)
);
return distance > exclusionRadius;
}

return monsters.map((monsterName, i) => {
let monsterX, monsterY;
do {
monsterX =
Math.floor(Math.random() * (mapWidth * 0.8 - mapWidth * 0.2)) +
mapWidth * 0.2;
monsterY =
Math.floor(Math.random() * (mapHeight * 0.8 - mapHeight * 0.2)) +
mapHeight * 0.2;
} while (!isOutsideExclusionZone(monsterX, monsterY));

return {
id: i.toString(),
x: monsterX,
y: monsterY,
className: monsterName,
isDragging: false,
};
});
}

The main change here is that we are now generating a random position for each monster, but we are also checking that the position is outside of the exclusion zone. If it is not, we generate a new position. We do this by using a do…while loop. This will keep generating a new position until the position is outside of the exclusion zone.

Next steps would be adding the structures

04.25.2023

Added Structures

I added the Castle, Tower and House Icon and moved to all icons to the following file:

import { Group, Path } from 'react-konva';

export const FighterIcon = ();

export const WizardIcon = ();

export const BarbarianIcon = ();

export const HouseIcon = ();

export const TowerIcon = ();

export const ObeliskIcon = ();

export const GoblinIcon = ();

export const DragonIcon = ();

export const SkeletonIcon = ();

I also refactored all components and types to be more readable and reusable.

The map file looks like this:

import React, { useState, useEffect } from 'react';
import { Stage, Layer } from 'react-konva';

import Party from './map-module/components/Party';
import Monsters from './map-module/components/Monsters';
import Structures from './map-module/components/Structures';

const KonvaMap = () => {
const defaultDragBoundFunc = (pos) => pos;

const handleTouchMove = (e) => {
e.evt.preventDefault();
};

const [stageSize, setStageSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});

useEffect(() => {
const handleResize = () => {
setStageSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};

}, []);

const playerRadius = 20;

const dragBoundFunc = (pos) => {
const x = Math.max(
playerRadius,
Math.min(pos.x, stageSize.width - playerRadius)
);
const y = Math.max(
playerRadius,
Math.min(pos.y, stageSize.height - playerRadius)
);
return { x, y };
};

return (

);
};

export default KonvaMap;