DND: Interactive Map
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;
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;