Add attachment token category
This commit is contained in:
parent
6eb1f71bc2
commit
73c549102b
@ -33,6 +33,7 @@ import {
|
|||||||
TokenStateChangeEventHandler,
|
TokenStateChangeEventHandler,
|
||||||
} from "../../types/Events";
|
} from "../../types/Events";
|
||||||
import Transformer from "./Transformer";
|
import Transformer from "./Transformer";
|
||||||
|
import TokenAttachment from "./TokenAttachment";
|
||||||
|
|
||||||
type MapTokenProps = {
|
type MapTokenProps = {
|
||||||
tokenState: TokenState;
|
tokenState: TokenState;
|
||||||
@ -76,14 +77,27 @@ function Token({
|
|||||||
|
|
||||||
const snapPositionToGrid = useGridSnapping();
|
const snapPositionToGrid = useGridSnapping();
|
||||||
|
|
||||||
const intersectingTokensRef = useRef<Konva.Node[]>([]);
|
const [dragging, setDragging] = useState(false);
|
||||||
const previousDragPositionRef = useRef({ x: 0, y: 0 });
|
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>) {
|
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") {
|
if (tokenState.category === "vehicle") {
|
||||||
previousDragPositionRef.current = tokenGroup.position();
|
|
||||||
const tokenIntersection = new Intersection(
|
const tokenIntersection = new Intersection(
|
||||||
getScaledOutline(tokenState, tokenWidth, tokenHeight),
|
getScaledOutline(tokenState, tokenWidth, tokenHeight),
|
||||||
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
|
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
|
||||||
@ -91,19 +105,44 @@ function Token({
|
|||||||
tokenState.rotation
|
tokenState.rotation
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find all other tokens on the map
|
// Find all other characters on the map and check whether they're
|
||||||
const layer = tokenGroup.getLayer() as Konva.Layer;
|
// intersecting the vehicle
|
||||||
const tokens = layer.find(".character");
|
const tokens = layer.find(".character");
|
||||||
for (let other of tokens) {
|
for (let other of tokens) {
|
||||||
if (other === tokenGroup) {
|
if (other === tokenGroup) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tokenIntersection.intersects(other.position())) {
|
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);
|
onTokenDragStart(event, tokenState.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,37 +152,54 @@ function Token({
|
|||||||
if (map.snapToGrid) {
|
if (map.snapToGrid) {
|
||||||
tokenGroup.position(snapPositionToGrid(tokenGroup.position()));
|
tokenGroup.position(snapPositionToGrid(tokenGroup.position()));
|
||||||
}
|
}
|
||||||
if (tokenState.category === "vehicle") {
|
if (attachedTokensRef.current.length > 0) {
|
||||||
const deltaPosition = Vector2.subtract(
|
const deltaPosition = Vector2.subtract(
|
||||||
tokenGroup.position(),
|
tokenGroup.position(),
|
||||||
previousDragPositionRef.current
|
previousDragPositionRef.current
|
||||||
);
|
);
|
||||||
for (let other of intersectingTokensRef.current) {
|
for (let other of attachedTokensRef.current) {
|
||||||
other.position(Vector2.add(other.position(), deltaPosition));
|
other.position(Vector2.add(other.position(), deltaPosition));
|
||||||
}
|
}
|
||||||
previousDragPositionRef.current = tokenGroup.position();
|
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>) {
|
function handleDragEnd(event: Konva.KonvaEventObject<DragEvent>) {
|
||||||
const tokenGroup = event.target;
|
const tokenGroup = event.target;
|
||||||
|
|
||||||
const mountChanges: Record<string, Partial<TokenState>> = {};
|
const attachedTokenChanges: Record<string, Partial<TokenState>> = {};
|
||||||
if (tokenState.category === "vehicle") {
|
if (attachedTokensRef.current.length > 0) {
|
||||||
for (let other of intersectingTokensRef.current) {
|
for (let other of attachedTokensRef.current) {
|
||||||
mountChanges[other.id()] = {
|
attachedTokenChanges[other.id()] = {
|
||||||
x: other.x() / mapWidth,
|
x: other.x() / mapWidth,
|
||||||
y: other.y() / mapHeight,
|
y: other.y() / mapHeight,
|
||||||
lastModifiedBy: userId,
|
lastModifiedBy: userId,
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
intersectingTokensRef.current = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreventMapInteraction(false);
|
setPreventMapInteraction(false);
|
||||||
onTokenStateChange({
|
onTokenStateChange({
|
||||||
...mountChanges,
|
...attachedTokenChanges,
|
||||||
[tokenState.id]: {
|
[tokenState.id]: {
|
||||||
x: tokenGroup.x() / mapWidth,
|
x: tokenGroup.x() / mapWidth,
|
||||||
y: tokenGroup.y() / mapHeight,
|
y: tokenGroup.y() / mapHeight,
|
||||||
@ -151,6 +207,12 @@ function Token({
|
|||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setDragging(false);
|
||||||
|
attachmentCharactersRef.current = [];
|
||||||
|
attachedTokensRef.current = [];
|
||||||
|
setAttachmentOverCharacter(false);
|
||||||
|
|
||||||
onTokenDragEnd(event, tokenState.id);
|
onTokenDragEnd(event, tokenState.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,6 +352,10 @@ function Token({
|
|||||||
<TokenOutline
|
<TokenOutline
|
||||||
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
|
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
|
||||||
hidden={!!tokenImage}
|
hidden={!!tokenImage}
|
||||||
|
// Disable hit detection for attachments
|
||||||
|
hitFunc={
|
||||||
|
tokenState.category === "attachment" ? () => {} : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<KonvaImage
|
<KonvaImage
|
||||||
@ -310,6 +376,18 @@ function Token({
|
|||||||
height={tokenHeight}
|
height={tokenHeight}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ? (
|
{tokenState.label ? (
|
||||||
<TokenLabel
|
<TokenLabel
|
||||||
tokenState={tokenState}
|
tokenState={tokenState}
|
||||||
|
52
src/components/konva/TokenAttachment.tsx
Normal file
52
src/components/konva/TokenAttachment.tsx
Normal 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;
|
@ -1,3 +1,4 @@
|
|||||||
|
import Konva from "konva";
|
||||||
import { Rect, Circle, Line } from "react-konva";
|
import { Rect, Circle, Line } from "react-konva";
|
||||||
|
|
||||||
import colors from "../../helpers/colors";
|
import colors from "../../helpers/colors";
|
||||||
@ -6,9 +7,9 @@ import { Outline } from "../../types/Outline";
|
|||||||
type TokenOutlineProps = {
|
type TokenOutlineProps = {
|
||||||
outline: Outline;
|
outline: Outline;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
};
|
} & Konva.ShapeConfig;
|
||||||
|
|
||||||
function TokenOutline({ outline, hidden }: TokenOutlineProps) {
|
function TokenOutline({ outline, hidden, ...props }: TokenOutlineProps) {
|
||||||
const sharedProps = {
|
const sharedProps = {
|
||||||
fill: colors.black,
|
fill: colors.black,
|
||||||
opacity: hidden ? 0 : 0.8,
|
opacity: hidden ? 0 : 0.8,
|
||||||
@ -21,6 +22,7 @@ function TokenOutline({ outline, hidden }: TokenOutlineProps) {
|
|||||||
x={outline.x}
|
x={outline.x}
|
||||||
y={outline.y}
|
y={outline.y}
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (outline.type === "circle") {
|
} else if (outline.type === "circle") {
|
||||||
@ -30,6 +32,7 @@ function TokenOutline({ outline, hidden }: TokenOutlineProps) {
|
|||||||
x={outline.x}
|
x={outline.x}
|
||||||
y={outline.y}
|
y={outline.y}
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -39,6 +42,7 @@ function TokenOutline({ outline, hidden }: TokenOutlineProps) {
|
|||||||
closed
|
closed
|
||||||
tension={outline.points.length < 200 ? 0 : 0.33}
|
tension={outline.points.length < 200 ? 0 : 0.33}
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -20,12 +20,15 @@ import { tokenSources } from "../../tokens";
|
|||||||
|
|
||||||
import Grid from "../konva/Grid";
|
import Grid from "../konva/Grid";
|
||||||
import { Token } from "../../types/Token";
|
import { Token } from "../../types/Token";
|
||||||
|
import SettingsContext, { useSettings } from "../../contexts/SettingsContext";
|
||||||
|
|
||||||
type TokenPreviewProps = {
|
type TokenPreviewProps = {
|
||||||
token: Token;
|
token: Token;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TokenPreview({ token }: TokenPreviewProps) {
|
function TokenPreview({ token }: TokenPreviewProps) {
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
const tokenURL = useDataURL(token, tokenSources);
|
const tokenURL = useDataURL(token, tokenSources);
|
||||||
const [tokenSourceImage] = useImage(tokenURL || "");
|
const [tokenSourceImage] = useImage(tokenURL || "");
|
||||||
|
|
||||||
@ -105,39 +108,41 @@ function TokenPreview({ token }: TokenPreviewProps) {
|
|||||||
scale={{ x: stageScale, y: stageScale }}
|
scale={{ x: stageScale, y: stageScale }}
|
||||||
ref={tokenStageRef}
|
ref={tokenStageRef}
|
||||||
>
|
>
|
||||||
<Layer ref={tokenLayerRef}>
|
<SettingsContext.Provider value={settings}>
|
||||||
<Image
|
<Layer ref={tokenLayerRef}>
|
||||||
image={tokenSourceImage}
|
<Image
|
||||||
width={tokenWidth}
|
image={tokenSourceImage}
|
||||||
height={tokenHeight}
|
width={tokenWidth}
|
||||||
/>
|
height={tokenHeight}
|
||||||
{showGridPreview && (
|
/>
|
||||||
<Group offsetY={gridHeight - tokenHeight}>
|
{showGridPreview && (
|
||||||
<GridProvider
|
<Group offsetY={gridHeight - tokenHeight}>
|
||||||
grid={{
|
<GridProvider
|
||||||
size: { x: gridX, y: gridY },
|
grid={{
|
||||||
inset: {
|
size: { x: gridX, y: gridY },
|
||||||
topLeft: { x: 0, y: 0 },
|
inset: {
|
||||||
bottomRight: { x: 1, y: 1 },
|
topLeft: { x: 0, y: 0 },
|
||||||
},
|
bottomRight: { x: 1, y: 1 },
|
||||||
type: "square",
|
},
|
||||||
measurement: { type: "chebyshev", scale: "5ft" },
|
type: "square",
|
||||||
}}
|
measurement: { type: "chebyshev", scale: "5ft" },
|
||||||
width={gridWidth}
|
}}
|
||||||
height={gridHeight}
|
width={gridWidth}
|
||||||
>
|
height={gridHeight}
|
||||||
<Grid />
|
>
|
||||||
</GridProvider>
|
<Grid />
|
||||||
<Rect
|
</GridProvider>
|
||||||
width={gridWidth}
|
<Rect
|
||||||
height={gridHeight}
|
width={gridWidth}
|
||||||
fill="transparent"
|
height={gridHeight}
|
||||||
stroke="rgba(255, 255, 255, 0.75)"
|
fill="transparent"
|
||||||
strokeWidth={borderWidth}
|
stroke="rgba(255, 255, 255, 0.75)"
|
||||||
/>
|
strokeWidth={borderWidth}
|
||||||
</Group>
|
/>
|
||||||
)}
|
</Group>
|
||||||
</Layer>
|
)}
|
||||||
|
</Layer>
|
||||||
|
</SettingsContext.Provider>
|
||||||
</Stage>
|
</Stage>
|
||||||
</ReactResizeDetector>
|
</ReactResizeDetector>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -12,6 +12,7 @@ const categorySettings: CategorySetting[] = [
|
|||||||
{ value: "character", label: "Character" },
|
{ value: "character", label: "Character" },
|
||||||
{ value: "prop", label: "Prop" },
|
{ value: "prop", label: "Prop" },
|
||||||
{ value: "vehicle", label: "Vehicle / Mount" },
|
{ value: "vehicle", label: "Vehicle / Mount" },
|
||||||
|
{ value: "attachment", label: "Attachment" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type TokenSettingsProps = {
|
type TokenSettingsProps = {
|
||||||
|
@ -147,12 +147,14 @@ export default useMapTokens;
|
|||||||
|
|
||||||
function getMapTokenCategoryWeight(category: TokenCategory) {
|
function getMapTokenCategoryWeight(category: TokenCategory) {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "character":
|
case "attachment":
|
||||||
return 0;
|
return 0;
|
||||||
case "vehicle":
|
case "character":
|
||||||
return 1;
|
return 1;
|
||||||
case "prop":
|
case "vehicle":
|
||||||
return 2;
|
return 2;
|
||||||
|
case "prop":
|
||||||
|
return 3;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@ -164,7 +166,7 @@ function sortMapTokenStates(
|
|||||||
b: TokenState,
|
b: TokenState,
|
||||||
tokenDraggingOptions?: TokenDraggingOptions
|
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) {
|
if (b.category !== a.category) {
|
||||||
const aWeight = getMapTokenCategoryWeight(a.category);
|
const aWeight = getMapTokenCategoryWeight(a.category);
|
||||||
const bWeight = getMapTokenCategoryWeight(b.category);
|
const bWeight = getMapTokenCategoryWeight(b.category);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import { Outline } from "./Outline";
|
import { Outline } from "./Outline";
|
||||||
|
|
||||||
export type TokenCategory = "character" | "vehicle" | "prop";
|
export type TokenCategory = "character" | "vehicle" | "prop" | "attachment";
|
||||||
|
|
||||||
export type BaseToken = {
|
export type BaseToken = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -16,7 +16,7 @@ export const TokenStateSchema = {
|
|||||||
],
|
],
|
||||||
definitions: {
|
definitions: {
|
||||||
TokenCategory: {
|
TokenCategory: {
|
||||||
enum: ["character", "prop", "vehicle"],
|
enum: ["character", "prop", "vehicle", "attachment"],
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
BaseTokenState: {
|
BaseTokenState: {
|
||||||
|
Loading…
Reference in New Issue
Block a user