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, 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}

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 { 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}
/> />
); );
} }

View File

@ -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,6 +108,7 @@ function TokenPreview({ token }: TokenPreviewProps) {
scale={{ x: stageScale, y: stageScale }} scale={{ x: stageScale, y: stageScale }}
ref={tokenStageRef} ref={tokenStageRef}
> >
<SettingsContext.Provider value={settings}>
<Layer ref={tokenLayerRef}> <Layer ref={tokenLayerRef}>
<Image <Image
image={tokenSourceImage} image={tokenSourceImage}
@ -138,6 +142,7 @@ function TokenPreview({ token }: TokenPreviewProps) {
</Group> </Group>
)} )}
</Layer> </Layer>
</SettingsContext.Provider>
</Stage> </Stage>
</ReactResizeDetector> </ReactResizeDetector>
<IconButton <IconButton

View File

@ -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 = {

View File

@ -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);

View File

@ -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;

View File

@ -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: {