Add attachment token category

This commit is contained in:
Mitchell McCaffrey 2021-08-05 11:56:40 +10:00
parent 6eb1f71bc2
commit 73c549102b
8 changed files with 197 additions and 55 deletions

View File

@ -33,6 +33,7 @@ import {
TokenStateChangeEventHandler,
} from "../../types/Events";
import Transformer from "./Transformer";
import TokenAttachment from "./TokenAttachment";
type MapTokenProps = {
tokenState: TokenState;
@ -76,14 +77,27 @@ function Token({
const snapPositionToGrid = useGridSnapping();
const intersectingTokensRef = useRef<Konva.Node[]>([]);
const [dragging, setDragging] = useState(false);
const previousDragPositionRef = useRef({ x: 0, y: 0 });
// Tokens that are attached to this token and should move when it moves
const attachedTokensRef = useRef<Konva.Node[]>([]);
// If this an attachment is it over a character
const [attachmentOverCharacter, setAttachmentOverCharacter] = useState(false);
// The characters that we're present when an attachment is dragged, used to highlight the attachment
const attachmentCharactersRef = useRef<Konva.Node[]>([]);
const attachmentThreshold = Vector2.componentMin(gridCellPixelSize) / 4;
function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) {
const tokenGroup = event.target;
const tokenGroup = event.target as Konva.Shape;
const layer = tokenGroup.getLayer();
if (!layer) {
return;
}
previousDragPositionRef.current = tokenGroup.position();
if (tokenState.category === "vehicle") {
previousDragPositionRef.current = tokenGroup.position();
const tokenIntersection = new Intersection(
getScaledOutline(tokenState, tokenWidth, tokenHeight),
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
@ -91,19 +105,44 @@ function Token({
tokenState.rotation
);
// Find all other tokens on the map
const layer = tokenGroup.getLayer() as Konva.Layer;
// Find all other characters on the map and check whether they're
// intersecting the vehicle
const tokens = layer.find(".character");
for (let other of tokens) {
if (other === tokenGroup) {
continue;
}
if (tokenIntersection.intersects(other.position())) {
intersectingTokensRef.current.push(other);
attachedTokensRef.current.push(other);
}
}
}
if (tokenState.category === "attachment") {
// If we're dragging an attachment add all characters to the attachment characters
// So we can check for highlights
previousDragPositionRef.current = tokenGroup.position();
const characters = layer.find(".character");
attachmentCharactersRef.current = characters;
}
if (tokenState.category === "character") {
// Find all attachments and check whether they are close to the center of this token
const attachments = layer.find(".attachment");
for (let attachment of attachments) {
if (attachment === tokenGroup) {
continue;
}
const distance = Vector2.distance(
tokenGroup.position(),
attachment.position()
);
if (distance < attachmentThreshold) {
attachedTokensRef.current.push(attachment);
}
}
}
setDragging(true);
onTokenDragStart(event, tokenState.id);
}
@ -113,37 +152,54 @@ function Token({
if (map.snapToGrid) {
tokenGroup.position(snapPositionToGrid(tokenGroup.position()));
}
if (tokenState.category === "vehicle") {
if (attachedTokensRef.current.length > 0) {
const deltaPosition = Vector2.subtract(
tokenGroup.position(),
previousDragPositionRef.current
);
for (let other of intersectingTokensRef.current) {
for (let other of attachedTokensRef.current) {
other.position(Vector2.add(other.position(), deltaPosition));
}
previousDragPositionRef.current = tokenGroup.position();
}
// Check whether an attachment is over a character
if (tokenState.category === "attachment") {
const characters = attachmentCharactersRef.current;
let overCharacter = false;
for (let character of characters) {
const distance = Vector2.distance(
tokenGroup.position(),
character.position()
);
if (distance < attachmentThreshold) {
overCharacter = true;
break;
}
}
if (attachmentOverCharacter !== overCharacter) {
setAttachmentOverCharacter(overCharacter);
}
}
}
function handleDragEnd(event: Konva.KonvaEventObject<DragEvent>) {
const tokenGroup = event.target;
const mountChanges: Record<string, Partial<TokenState>> = {};
if (tokenState.category === "vehicle") {
for (let other of intersectingTokensRef.current) {
mountChanges[other.id()] = {
const attachedTokenChanges: Record<string, Partial<TokenState>> = {};
if (attachedTokensRef.current.length > 0) {
for (let other of attachedTokensRef.current) {
attachedTokenChanges[other.id()] = {
x: other.x() / mapWidth,
y: other.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
};
}
intersectingTokensRef.current = [];
}
setPreventMapInteraction(false);
onTokenStateChange({
...mountChanges,
...attachedTokenChanges,
[tokenState.id]: {
x: tokenGroup.x() / mapWidth,
y: tokenGroup.y() / mapHeight,
@ -151,6 +207,12 @@ function Token({
lastModified: Date.now(),
},
});
setDragging(false);
attachmentCharactersRef.current = [];
attachedTokensRef.current = [];
setAttachmentOverCharacter(false);
onTokenDragEnd(event, tokenState.id);
}
@ -290,6 +352,10 @@ function Token({
<TokenOutline
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
hidden={!!tokenImage}
// Disable hit detection for attachments
hitFunc={
tokenState.category === "attachment" ? () => {} : undefined
}
/>
</Group>
<KonvaImage
@ -310,6 +376,18 @@ function Token({
height={tokenHeight}
/>
) : null}
{tokenState.category === "attachment" ? (
<Group offsetX={-tokenWidth / 2} offsetY={-tokenHeight / 2}>
<Group rotation={tokenState.rotation}>
<TokenAttachment
tokenHeight={tokenHeight}
dragging={dragging}
highlight={attachmentOverCharacter}
radius={attachmentThreshold * 2}
/>
</Group>
</Group>
) : null}
{tokenState.label ? (
<TokenLabel
tokenState={tokenState}

View File

@ -0,0 +1,52 @@
import { Circle, Group, Rect } from "react-konva";
type TokenAttachmentProps = {
tokenHeight: number;
dragging: boolean;
highlight: boolean;
radius: number;
};
function TokenAttachment({
tokenHeight,
dragging,
highlight,
radius,
}: TokenAttachmentProps) {
return (
<Group>
{/* Make a bigger hidden rect for hit registration */}
<Rect
width={10}
height={5}
x={-5}
y={tokenHeight / 2 - 2.5}
cornerRadius={2.5}
fill="transparent"
/>
<Rect
width={5}
height={2}
x={-2.5}
y={tokenHeight / 2 - 1}
cornerRadius={2.5}
fill="rgba(36, 39, 51, 0.5)"
shadowColor="rgba(0,0,0,0.12)"
shadowOffsetY={1}
shadowBlur={2}
hitFunc={() => {}}
/>
{dragging ? (
<Circle
radius={radius}
stroke={
highlight ? "hsl(260, 100%, 80%)" : "rgba(255, 255, 255, 0.85)"
}
strokeWidth={0.5}
/>
) : null}
</Group>
);
}
export default TokenAttachment;

View File

@ -1,3 +1,4 @@
import Konva from "konva";
import { Rect, Circle, Line } from "react-konva";
import colors from "../../helpers/colors";
@ -6,9 +7,9 @@ import { Outline } from "../../types/Outline";
type TokenOutlineProps = {
outline: Outline;
hidden: boolean;
};
} & Konva.ShapeConfig;
function TokenOutline({ outline, hidden }: TokenOutlineProps) {
function TokenOutline({ outline, hidden, ...props }: TokenOutlineProps) {
const sharedProps = {
fill: colors.black,
opacity: hidden ? 0 : 0.8,
@ -21,6 +22,7 @@ function TokenOutline({ outline, hidden }: TokenOutlineProps) {
x={outline.x}
y={outline.y}
{...sharedProps}
{...props}
/>
);
} else if (outline.type === "circle") {
@ -30,6 +32,7 @@ function TokenOutline({ outline, hidden }: TokenOutlineProps) {
x={outline.x}
y={outline.y}
{...sharedProps}
{...props}
/>
);
} else {
@ -39,6 +42,7 @@ function TokenOutline({ outline, hidden }: TokenOutlineProps) {
closed
tension={outline.points.length < 200 ? 0 : 0.33}
{...sharedProps}
{...props}
/>
);
}

View File

@ -20,12 +20,15 @@ import { tokenSources } from "../../tokens";
import Grid from "../konva/Grid";
import { Token } from "../../types/Token";
import SettingsContext, { useSettings } from "../../contexts/SettingsContext";
type TokenPreviewProps = {
token: Token;
};
function TokenPreview({ token }: TokenPreviewProps) {
const settings = useSettings();
const tokenURL = useDataURL(token, tokenSources);
const [tokenSourceImage] = useImage(tokenURL || "");
@ -105,39 +108,41 @@ function TokenPreview({ token }: TokenPreviewProps) {
scale={{ x: stageScale, y: stageScale }}
ref={tokenStageRef}
>
<Layer ref={tokenLayerRef}>
<Image
image={tokenSourceImage}
width={tokenWidth}
height={tokenHeight}
/>
{showGridPreview && (
<Group offsetY={gridHeight - tokenHeight}>
<GridProvider
grid={{
size: { x: gridX, y: gridY },
inset: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
type: "square",
measurement: { type: "chebyshev", scale: "5ft" },
}}
width={gridWidth}
height={gridHeight}
>
<Grid />
</GridProvider>
<Rect
width={gridWidth}
height={gridHeight}
fill="transparent"
stroke="rgba(255, 255, 255, 0.75)"
strokeWidth={borderWidth}
/>
</Group>
)}
</Layer>
<SettingsContext.Provider value={settings}>
<Layer ref={tokenLayerRef}>
<Image
image={tokenSourceImage}
width={tokenWidth}
height={tokenHeight}
/>
{showGridPreview && (
<Group offsetY={gridHeight - tokenHeight}>
<GridProvider
grid={{
size: { x: gridX, y: gridY },
inset: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
type: "square",
measurement: { type: "chebyshev", scale: "5ft" },
}}
width={gridWidth}
height={gridHeight}
>
<Grid />
</GridProvider>
<Rect
width={gridWidth}
height={gridHeight}
fill="transparent"
stroke="rgba(255, 255, 255, 0.75)"
strokeWidth={borderWidth}
/>
</Group>
)}
</Layer>
</SettingsContext.Provider>
</Stage>
</ReactResizeDetector>
<IconButton

View File

@ -12,6 +12,7 @@ const categorySettings: CategorySetting[] = [
{ value: "character", label: "Character" },
{ value: "prop", label: "Prop" },
{ value: "vehicle", label: "Vehicle / Mount" },
{ value: "attachment", label: "Attachment" },
];
type TokenSettingsProps = {

View File

@ -147,12 +147,14 @@ export default useMapTokens;
function getMapTokenCategoryWeight(category: TokenCategory) {
switch (category) {
case "character":
case "attachment":
return 0;
case "vehicle":
case "character":
return 1;
case "prop":
case "vehicle":
return 2;
case "prop":
return 3;
default:
return 0;
}
@ -164,7 +166,7 @@ function sortMapTokenStates(
b: TokenState,
tokenDraggingOptions?: TokenDraggingOptions
) {
// If categories are different sort in order "prop", "vehicle", "character"
// If categories are different sort in order "prop", "vehicle", "character", "attachment"
if (b.category !== a.category) {
const aWeight = getMapTokenCategoryWeight(a.category);
const bWeight = getMapTokenCategoryWeight(b.category);

View File

@ -1,7 +1,7 @@
import Konva from "konva";
import { Outline } from "./Outline";
export type TokenCategory = "character" | "vehicle" | "prop";
export type TokenCategory = "character" | "vehicle" | "prop" | "attachment";
export type BaseToken = {
id: string;

View File

@ -16,7 +16,7 @@ export const TokenStateSchema = {
],
definitions: {
TokenCategory: {
enum: ["character", "prop", "vehicle"],
enum: ["character", "prop", "vehicle", "attachment"],
type: "string",
},
BaseTokenState: {