Merge branch 'develop' into feat/e-bike
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
This commit is contained in:
@@ -15,11 +15,15 @@ import {
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION,
|
||||
REPAIR_CASE_PART_ANCHOR_FALLBACKS,
|
||||
REPAIR_CASE_PART_ANCHOR_NAMES,
|
||||
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
|
||||
REPAIR_CASE_POP_DURATION,
|
||||
REPAIR_CASE_POP_Y_OFFSET,
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
type RepairCasePartAnchorName,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
@@ -32,6 +36,10 @@ export interface RepairCasePlaceholder {
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
export type RepairCasePartAnchors = Partial<
|
||||
Record<RepairCasePartAnchorName, Vector3Tuple>
|
||||
>;
|
||||
|
||||
interface RepairCaseModelProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
open: boolean;
|
||||
@@ -40,6 +48,7 @@ interface RepairCaseModelProps extends ModelTransformProps {
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onAnchorsChange?: ((anchors: RepairCasePartAnchors) => void) | undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
@@ -59,6 +68,7 @@ export function RepairCaseModel({
|
||||
exiting = false,
|
||||
floating = true,
|
||||
onPlaceholdersChange,
|
||||
onAnchorsChange,
|
||||
onExitComplete,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
@@ -81,6 +91,7 @@ export function RepairCaseModel({
|
||||
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
|
||||
const onExitCompleteRef = useRef(onExitComplete);
|
||||
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
|
||||
const onAnchorsChangeRef = useRef(onAnchorsChange);
|
||||
const initialOpen = useRef(open);
|
||||
const previousOpen = useRef(open);
|
||||
const openedRotationZ = useRef(0);
|
||||
@@ -89,6 +100,12 @@ export function RepairCaseModel({
|
||||
const placeholderSignature = useRef("__initial__");
|
||||
const placeholderPosition = useRef(new THREE.Vector3());
|
||||
const placeholderLocalPosition = useRef(new THREE.Vector3());
|
||||
const anchorNodes = useRef<Map<RepairCasePartAnchorName, THREE.Object3D>>(
|
||||
new Map(),
|
||||
);
|
||||
const anchorSignature = useRef("__initial__");
|
||||
const anchorWorldPosition = useRef(new THREE.Vector3());
|
||||
const anchorLocalPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
onExitCompleteRef.current = onExitComplete;
|
||||
@@ -98,6 +115,10 @@ export function RepairCaseModel({
|
||||
onPlaceholdersChangeRef.current = onPlaceholdersChange;
|
||||
}, [onPlaceholdersChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onAnchorsChangeRef.current = onAnchorsChange;
|
||||
}, [onAnchorsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const popAnimation = pop.current;
|
||||
|
||||
@@ -153,6 +174,37 @@ export function RepairCaseModel({
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve part anchor nodes (cabledroit, cablegauche, pucehaut, pucebas,
|
||||
// refroidisseur). Existing GLTF nodes are reused and their meshes are
|
||||
// hidden so the standalone model injected at the same position becomes
|
||||
// the only visible representation. Missing nodes are created on the fly
|
||||
// at the configured fallback case-local position.
|
||||
anchorNodes.current = new Map();
|
||||
REPAIR_CASE_PART_ANCHOR_NAMES.forEach((anchorName) => {
|
||||
let node = model.getObjectByName(anchorName);
|
||||
if (node) {
|
||||
node.traverse((descendant) => {
|
||||
if ((descendant as THREE.Mesh).isMesh) {
|
||||
descendant.visible = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const placeholder = new THREE.Object3D();
|
||||
placeholder.name = anchorName;
|
||||
const fallback = REPAIR_CASE_PART_ANCHOR_FALLBACKS[anchorName];
|
||||
placeholder.position.set(fallback[0], fallback[1], fallback[2]);
|
||||
placeholder.quaternion.set(
|
||||
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[0],
|
||||
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[1],
|
||||
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[2],
|
||||
REPAIR_CASE_PART_ANCHOR_FALLBACK_QUATERNION[3],
|
||||
);
|
||||
model.add(placeholder);
|
||||
node = placeholder;
|
||||
}
|
||||
anchorNodes.current.set(anchorName, node);
|
||||
});
|
||||
|
||||
if (lid) {
|
||||
lid.rotation.z =
|
||||
openedRotationZ.current +
|
||||
@@ -250,6 +302,31 @@ export function RepairCaseModel({
|
||||
}
|
||||
}
|
||||
|
||||
if (anchorNodes.current.size > 0) {
|
||||
const anchors: RepairCasePartAnchors = {};
|
||||
const signatureParts: string[] = [];
|
||||
anchorNodes.current.forEach((node, anchorName) => {
|
||||
node.getWorldPosition(anchorWorldPosition.current);
|
||||
anchorLocalPosition.current.copy(anchorWorldPosition.current);
|
||||
group.parent?.worldToLocal(anchorLocalPosition.current);
|
||||
const tuple: Vector3Tuple = [
|
||||
anchorLocalPosition.current.x,
|
||||
anchorLocalPosition.current.y,
|
||||
anchorLocalPosition.current.z,
|
||||
];
|
||||
anchors[anchorName] = tuple;
|
||||
signatureParts.push(
|
||||
`${anchorName}:${tuple.map((value) => value.toFixed(3)).join(",")}`,
|
||||
);
|
||||
});
|
||||
signatureParts.sort();
|
||||
const nextAnchorSignature = signatureParts.join("|");
|
||||
if (nextAnchorSignature !== anchorSignature.current) {
|
||||
anchorSignature.current = nextAnchorSignature;
|
||||
onAnchorsChangeRef.current?.(anchors);
|
||||
}
|
||||
}
|
||||
|
||||
animationActiveRef.current = isNear;
|
||||
|
||||
if (animationActiveRef.current) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel";
|
||||
import type {
|
||||
RepairCasePartAnchors,
|
||||
RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
@@ -63,12 +67,15 @@ export function RepairGame({
|
||||
const [casePlaceholders, setCasePlaceholders] = useState<
|
||||
readonly RepairCasePlaceholder[]
|
||||
>([]);
|
||||
const [caseAnchors, setCaseAnchors] = useState<RepairCasePartAnchors>({});
|
||||
const [brokenAnchors, setBrokenAnchors] = useState<ExplodedNodeAnchors>({});
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]);
|
||||
|
||||
useRepairFragmentationInput({
|
||||
enabled: mainState === mission && readyForFragmentation,
|
||||
@@ -81,6 +88,8 @@ export function RepairGame({
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCasePlaceholders([]);
|
||||
setCaseAnchors({});
|
||||
setBrokenAnchors({});
|
||||
setScannedBrokenParts([]);
|
||||
}, 0);
|
||||
|
||||
@@ -136,12 +145,24 @@ export function RepairGame({
|
||||
/>
|
||||
) : null}
|
||||
{step === "repairing" ? (
|
||||
<RepairRepairingStep
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
<>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
hideNodeNames={brokenNodeNames}
|
||||
nodeAnchorNames={brokenNodeNames}
|
||||
onNodeAnchorsChange={setBrokenAnchors}
|
||||
/>
|
||||
<RepairRepairingStep
|
||||
anchors={caseAnchors}
|
||||
brokenAnchors={brokenAnchors}
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{step === "reassembling" ? (
|
||||
<RepairReassemblyStep
|
||||
@@ -159,6 +180,7 @@ export function RepairGame({
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
onPlaceholdersChange={setCasePlaceholders}
|
||||
onAnchorsChange={setCaseAnchors}
|
||||
open={step === "repairing"}
|
||||
zoomed={step === "repairing"}
|
||||
showFragmentationPrompt={readyForFragmentation}
|
||||
@@ -188,3 +210,15 @@ function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function getBrokenNodeNames(config: RepairMissionConfig): readonly string[] {
|
||||
const names = new Set<string>();
|
||||
config.brokenParts.forEach((part) => {
|
||||
if (part.targetNodeName) names.add(part.targetNodeName);
|
||||
else if (part.nodeName) names.add(part.nodeName);
|
||||
});
|
||||
config.replacementParts.forEach((part) => {
|
||||
if (part.targetNodeName) names.add(part.targetNodeName);
|
||||
});
|
||||
return Array.from(names);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
RepairCaseModel,
|
||||
type RepairCasePartAnchors,
|
||||
type RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
@@ -19,6 +20,7 @@ interface RepairMissionCaseProps {
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onAnchorsChange?: ((anchors: RepairCasePartAnchors) => void) | undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
open?: boolean;
|
||||
zoomed?: boolean;
|
||||
@@ -30,6 +32,7 @@ export function RepairMissionCase({
|
||||
config,
|
||||
exiting = false,
|
||||
onPlaceholdersChange,
|
||||
onAnchorsChange,
|
||||
onExitComplete,
|
||||
open = false,
|
||||
zoomed = false,
|
||||
@@ -57,6 +60,7 @@ export function RepairMissionCase({
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
onAnchorsChange={onAnchorsChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
@@ -70,6 +74,7 @@ export function RepairMissionCase({
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
onAnchorsChange={onAnchorsChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toVector3Scale } from "@/utils/three/scale";
|
||||
interface RepairObjectModelProps extends ModelTransformProps {
|
||||
label: string;
|
||||
modelPath: string;
|
||||
ghosted?: boolean;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
|
||||
@@ -73,6 +74,7 @@ export function RepairObjectModel({
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
ghosted = false,
|
||||
}: RepairObjectModelProps): React.JSX.Element {
|
||||
return (
|
||||
<RepairObjectModelBoundary
|
||||
@@ -87,6 +89,7 @@ export function RepairObjectModel({
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
opacity={ghosted ? 0.35 : 1}
|
||||
/>
|
||||
</RepairObjectModelBoundary>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import type {
|
||||
RepairCasePartAnchors,
|
||||
RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
@@ -38,6 +42,8 @@ const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||
let hasWarnedMissingPlaceholders = false;
|
||||
|
||||
interface RepairRepairingStepProps {
|
||||
anchors?: RepairCasePartAnchors;
|
||||
brokenAnchors?: ExplodedNodeAnchors;
|
||||
brokenParts: readonly RepairScannedBrokenPart[];
|
||||
config: RepairMissionConfig;
|
||||
placeholders: readonly RepairCasePlaceholder[];
|
||||
@@ -63,6 +69,8 @@ interface RepairPartPlacementFeedbackProps {
|
||||
}
|
||||
|
||||
export function RepairRepairingStep({
|
||||
anchors = {},
|
||||
brokenAnchors = {},
|
||||
brokenParts,
|
||||
config,
|
||||
placeholders,
|
||||
@@ -76,12 +84,15 @@ export function RepairRepairingStep({
|
||||
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [heldPartByLockGroup, setHeldPartByLockGroup] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
|
||||
useState(false);
|
||||
const replacementParts = getReplacementParts(config);
|
||||
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
||||
const requiredReplacementPart = replacementParts.find(
|
||||
(part) => part.id === config.requiredReplacementPartId,
|
||||
const requiredReplacementPart = replacementParts.find((part) =>
|
||||
config.requiredReplacementPartIds.includes(part.id),
|
||||
);
|
||||
const requiredReplacementLabel =
|
||||
requiredReplacementPart?.label ?? config.label;
|
||||
@@ -89,15 +100,16 @@ export function RepairRepairingStep({
|
||||
const placeholderPositions = placeholderTargets.map(
|
||||
(target) => target.position,
|
||||
);
|
||||
const hasCorrectPartPlaced = Boolean(
|
||||
placedPartIds[config.requiredReplacementPartId],
|
||||
const hasCorrectPartPlaced = config.requiredReplacementPartIds.some(
|
||||
(id) => placedPartIds[id],
|
||||
);
|
||||
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
||||
(part) => depositedBrokenPartIds[part.id],
|
||||
);
|
||||
const hasWrongPartPlaced = replacementParts.some(
|
||||
(part) =>
|
||||
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
|
||||
!config.requiredReplacementPartIds.includes(part.id) &&
|
||||
placedPartIds[part.id],
|
||||
);
|
||||
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
||||
const installColor = isReadyToInstall
|
||||
@@ -177,6 +189,24 @@ export function RepairRepairingStep({
|
||||
});
|
||||
}
|
||||
|
||||
function handleReplacementGrabChange(
|
||||
part: RepairMissionPartConfig,
|
||||
held: boolean,
|
||||
): void {
|
||||
if (!part.caseLockGroup) return;
|
||||
const group = part.caseLockGroup;
|
||||
setHeldPartByLockGroup((current) => {
|
||||
if (held) {
|
||||
if (current[group] === part.id) return current;
|
||||
return { ...current, [group]: part.id };
|
||||
}
|
||||
if (current[group] !== part.id) return current;
|
||||
const next = { ...current };
|
||||
delete next[group];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairInstallTarget
|
||||
@@ -192,15 +222,23 @@ export function RepairRepairingStep({
|
||||
<RepairPlaceholderMarkers positions={placeholderPositions} />
|
||||
|
||||
{replacementParts.map((part, index) => {
|
||||
const anchorPosition = part.caseAnchor
|
||||
? anchors[part.caseAnchor]
|
||||
: undefined;
|
||||
const placeholderPosition =
|
||||
anchorPosition ??
|
||||
placeholderPositions[index % placeholderPositions.length] ??
|
||||
placeholderPositions[0]!;
|
||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||
const feedbackState = getReplacementFeedbackState(
|
||||
part.id,
|
||||
config.requiredReplacementPartId,
|
||||
config.requiredReplacementPartIds,
|
||||
isPlaced,
|
||||
);
|
||||
const lockedByOther =
|
||||
part.caseLockGroup !== undefined &&
|
||||
heldPartByLockGroup[part.caseLockGroup] !== undefined &&
|
||||
heldPartByLockGroup[part.caseLockGroup] !== part.id;
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
@@ -208,7 +246,11 @@ export function RepairRepairingStep({
|
||||
position={placeholderPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
disabled={lockedByOther}
|
||||
label={`Prendre ${part.label}`}
|
||||
onGrabChange={(held) => {
|
||||
handleReplacementGrabChange(part, held);
|
||||
}}
|
||||
onPositionChange={(position) => {
|
||||
handleReplacementPosition(part.id, position);
|
||||
}}
|
||||
@@ -224,6 +266,7 @@ export function RepairRepairingStep({
|
||||
label={part.label}
|
||||
modelPath={part.modelPath ?? config.modelPath}
|
||||
scale={0.36}
|
||||
ghosted={lockedByOther}
|
||||
/>
|
||||
<RepairPartPlacementFeedback state={feedbackState} />
|
||||
</group>
|
||||
@@ -232,14 +275,18 @@ export function RepairRepairingStep({
|
||||
})}
|
||||
|
||||
{brokenPartsToDeposit.map((part, index) => {
|
||||
const startOffset =
|
||||
const fallbackOffset =
|
||||
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
|
||||
BROKEN_PART_START_OFFSETS[0]!;
|
||||
const startPosition: Vector3Tuple = [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
|
||||
const fallbackPosition: Vector3Tuple = [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + fallbackOffset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + fallbackOffset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + fallbackOffset[2],
|
||||
];
|
||||
const anchorPosition = part.targetNodeName
|
||||
? brokenAnchors[part.targetNodeName]
|
||||
: undefined;
|
||||
const startPosition: Vector3Tuple = anchorPosition ?? fallbackPosition;
|
||||
const targetPositions = getBrokenPartTargetPositions(
|
||||
part,
|
||||
placeholderTargets,
|
||||
@@ -387,12 +434,12 @@ function getPlacementFeedbackColor(
|
||||
|
||||
function getReplacementFeedbackState(
|
||||
partId: string,
|
||||
requiredPartId: string,
|
||||
requiredPartIds: readonly string[],
|
||||
isPlaced: boolean,
|
||||
): RepairPartPlacementFeedbackProps["state"] {
|
||||
if (!isPlaced) return null;
|
||||
|
||||
return partId === requiredPartId ? "valid" : "invalid";
|
||||
return requiredPartIds.includes(partId) ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
function getPlaceholderTargets(
|
||||
@@ -466,9 +513,12 @@ function getReplacementParts(
|
||||
): readonly RepairMissionPartConfig[] {
|
||||
if (config.replacementParts.length > 0) return config.replacementParts;
|
||||
|
||||
const fallbackId =
|
||||
config.requiredReplacementPartIds[0] ?? `${config.id}-replacement`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: config.requiredReplacementPartId,
|
||||
id: fallbackId,
|
||||
label: config.label,
|
||||
modelPath: config.modelPath,
|
||||
},
|
||||
@@ -486,5 +536,6 @@ function getBrokenPartsToDeposit(
|
||||
label: part.label,
|
||||
modelPath: part.modelPath ?? config.modelPath,
|
||||
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
|
||||
...(part.targetNodeName ? { targetNodeName: part.targetNodeName } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ function getScannedBrokenParts(
|
||||
...(match.config.caseSlotName
|
||||
? { caseSlotName: match.config.caseSlotName }
|
||||
: {}),
|
||||
...(match.config.targetNodeName
|
||||
? { targetNodeName: match.config.targetNodeName }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { HandTrackingLandmark } from "@/types/handTracking/handTracking";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
|
||||
// Both gloves share the same source mesh (gant_l). The right glove is
|
||||
// rendered by mirroring scale.x at the group level — this is more
|
||||
// reliable than the historical gant_r GLTF, which embeds multiple
|
||||
// skeletons (Hand_l, Hand_l_pad, Hand_r) and was breaking the finger
|
||||
// rig.
|
||||
const GLOVE_CONFIGS: Record<
|
||||
HandTrackingGloveHandedness,
|
||||
{
|
||||
@@ -24,8 +29,8 @@ const GLOVE_CONFIGS: Record<
|
||||
rootNodeName: "Armature",
|
||||
},
|
||||
right: {
|
||||
modelPath: "/models/gant_r/model.gltf",
|
||||
rootNodeName: "Hand_r",
|
||||
modelPath: "/models/gant_l/model.gltf",
|
||||
rootNodeName: "Armature",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -226,7 +231,10 @@ function applyFingerPose(
|
||||
_boneTargetQuaternion
|
||||
.copy(_boneDeltaQuaternion)
|
||||
.multiply(pose.restQuaternion);
|
||||
pose.bone.quaternion.slerp(_boneTargetQuaternion, 0.45);
|
||||
// Lower slerp factor = smoother but more latency. MediaPipe at
|
||||
// ~10fps produces noisy landmark frames; smoothing cuts the
|
||||
// jitter the user sees on every finger bone.
|
||||
pose.bone.quaternion.slerp(_boneTargetQuaternion, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,12 +342,18 @@ function HandTrackingGloveModel({
|
||||
_matrix.makeBasis(_xAxis, _yAxis, _zAxis);
|
||||
_targetQuaternion.setFromRotationMatrix(_matrix);
|
||||
|
||||
group.position.lerp(_targetPosition, Math.min(1, delta * 18));
|
||||
group.quaternion.slerp(_targetQuaternion, Math.min(1, delta * 18));
|
||||
// Lower factor (was 18) damps the glove jitter caused by noisy
|
||||
// landmarks while keeping a responsive feel.
|
||||
group.position.lerp(_targetPosition, Math.min(1, delta * 12));
|
||||
group.quaternion.slerp(_targetQuaternion, Math.min(1, delta * 12));
|
||||
|
||||
const palmLength = _wristPosition.distanceTo(_middlePosition);
|
||||
const scale = palmLength * GLOVE_MODEL_SCALE;
|
||||
group.scale.setScalar(scale);
|
||||
// Both gloves use the gant_l mesh; flip X for the right hand so the
|
||||
// thumb ends up on the correct side instead of being a left-glove
|
||||
// clone on the right hand.
|
||||
const mirrorSignX = handedness === "right" ? -1 : 1;
|
||||
group.scale.set(scale * mirrorSignX, scale, scale);
|
||||
group.updateMatrixWorld(true);
|
||||
applyFingerPose(fingerPoseChains, trackedHand.landmarks, camera);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ interface GrabbableObjectProps {
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
handControlled?: boolean;
|
||||
disabled?: boolean;
|
||||
onGrabChange?: (held: boolean) => void;
|
||||
onPositionChange?: (position: THREE.Vector3) => void;
|
||||
onSnap?: (position: THREE.Vector3) => void;
|
||||
snapDuration?: number;
|
||||
@@ -131,6 +133,8 @@ export function GrabbableObject({
|
||||
colliders = GRAB_DEFAULT_COLLIDERS,
|
||||
label = GRAB_DEFAULT_LABEL,
|
||||
handControlled = false,
|
||||
disabled = false,
|
||||
onGrabChange,
|
||||
onPositionChange,
|
||||
onSnap,
|
||||
snapDuration = 0.25,
|
||||
@@ -152,6 +156,19 @@ export function GrabbableObject({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
if (isHolding.current) {
|
||||
isHolding.current = false;
|
||||
onGrabChange?.(false);
|
||||
}
|
||||
if (isHandHolding.current) {
|
||||
isHandHolding.current = false;
|
||||
InteractionManager.getInstance().setHandHolding(false);
|
||||
onGrabChange?.(false);
|
||||
}
|
||||
}, [disabled, onGrabChange]);
|
||||
|
||||
function snapToNearestTarget(): void {
|
||||
const body = rbRef.current;
|
||||
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
|
||||
@@ -242,14 +259,16 @@ export function GrabbableObject({
|
||||
useFrame(() => {
|
||||
if (!rbRef.current) return;
|
||||
|
||||
const fistHand = handControlled
|
||||
? hands.find((hand) => hand.isFist)
|
||||
: undefined;
|
||||
|
||||
const t = rbRef.current.translation();
|
||||
_currentPos.set(t.x, t.y, t.z);
|
||||
onPositionChange?.(_currentPos);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const fistHand = handControlled
|
||||
? hands.find((hand) => hand.isFist)
|
||||
: undefined;
|
||||
|
||||
if (fistHand) {
|
||||
const handCenter = getHandCenterPoint(fistHand);
|
||||
|
||||
@@ -267,15 +286,20 @@ export function GrabbableObject({
|
||||
? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
|
||||
: null;
|
||||
|
||||
isHandHolding.current = Boolean(hit);
|
||||
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
|
||||
const hadHit = Boolean(hit);
|
||||
if (hadHit) {
|
||||
isHandHolding.current = true;
|
||||
InteractionManager.getInstance().setHandHolding(true);
|
||||
onGrabChange?.(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isHandHolding.current) {
|
||||
snapToNearestTarget();
|
||||
isHandHolding.current = false;
|
||||
InteractionManager.getInstance().setHandHolding(false);
|
||||
onGrabChange?.(false);
|
||||
}
|
||||
isHandHolding.current = false;
|
||||
InteractionManager.getInstance().setHandHolding(false);
|
||||
}
|
||||
|
||||
if (!isHolding.current && !isHandHolding.current) return;
|
||||
@@ -311,35 +335,41 @@ export function GrabbableObject({
|
||||
position={position}
|
||||
>
|
||||
<group ref={groupRef}>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
snapToNearestTarget();
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
{disabled ? (
|
||||
children
|
||||
) : (
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
onGrabChange?.(true);
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
onGrabChange?.(false);
|
||||
snapToNearestTarget();
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
)}
|
||||
</group>
|
||||
</RigidBody>
|
||||
</group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo } from "react";
|
||||
import { Component, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
@@ -9,6 +10,10 @@ import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
export type ExplodedNodeAnchors = Readonly<Record<string, Vector3Tuple>>;
|
||||
|
||||
const _anchorWorld = new THREE.Vector3();
|
||||
|
||||
interface ModelErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
modelPath: string;
|
||||
@@ -67,6 +72,9 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
|
||||
split: boolean;
|
||||
splitDistance?: number;
|
||||
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
|
||||
hideNodeNames?: readonly string[];
|
||||
nodeAnchorNames?: readonly string[];
|
||||
onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void;
|
||||
}
|
||||
|
||||
export function ExplodableModel(
|
||||
@@ -93,6 +101,9 @@ function ExplodableModelInner({
|
||||
scale = 1,
|
||||
splitDistance = 1.2,
|
||||
onPartsReady,
|
||||
hideNodeNames,
|
||||
nodeAnchorNames,
|
||||
onNodeAnchorsChange,
|
||||
}: ExplodableModelInnerProps): React.JSX.Element {
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "ExplodableModel",
|
||||
@@ -106,6 +117,24 @@ function ExplodableModelInner({
|
||||
[model, splitDistance],
|
||||
);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const anchorSignatureRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideNodeNames || hideNodeNames.length === 0) return;
|
||||
const hidden: THREE.Object3D[] = [];
|
||||
model.traverse((child) => {
|
||||
if (hideNodeNames.includes(child.name)) {
|
||||
hidden.push(child);
|
||||
child.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
hidden.forEach((object) => {
|
||||
object.visible = true;
|
||||
});
|
||||
};
|
||||
}, [hideNodeNames, model]);
|
||||
|
||||
useEffect(() => {
|
||||
explodedModel.setSplit(split);
|
||||
@@ -117,6 +146,35 @@ function ExplodableModelInner({
|
||||
|
||||
useFrame((_, delta) => {
|
||||
explodedModel.update(delta);
|
||||
|
||||
if (
|
||||
!onNodeAnchorsChange ||
|
||||
!nodeAnchorNames ||
|
||||
nodeAnchorNames.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchors: Record<string, Vector3Tuple> = {};
|
||||
nodeAnchorNames.forEach((name) => {
|
||||
const node = model.getObjectByName(name);
|
||||
if (!node) return;
|
||||
node.getWorldPosition(_anchorWorld);
|
||||
anchors[name] = [_anchorWorld.x, _anchorWorld.y, _anchorWorld.z];
|
||||
});
|
||||
|
||||
const signature = nodeAnchorNames
|
||||
.map((name) => {
|
||||
const a = anchors[name];
|
||||
return a
|
||||
? `${name}:${a[0].toFixed(3)},${a[1].toFixed(3)},${a[2].toFixed(3)}`
|
||||
: `${name}:?`;
|
||||
})
|
||||
.join("|");
|
||||
|
||||
if (signature === anchorSignatureRef.current) return;
|
||||
anchorSignatureRef.current = signature;
|
||||
onNodeAnchorsChange(anchors);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,10 +17,29 @@ function applyShadowSettings(
|
||||
});
|
||||
}
|
||||
|
||||
function applyOpacity(object: THREE.Object3D, opacity: number): void {
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
|
||||
materials.forEach((material) => {
|
||||
if (!(material instanceof THREE.Material)) return;
|
||||
material.transparent = opacity < 1;
|
||||
material.opacity = opacity;
|
||||
material.depthWrite = opacity >= 1;
|
||||
material.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface SimpleModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface SimpleModelProps extends SimpleModelConfig {
|
||||
@@ -34,6 +53,7 @@ export function SimpleModel({
|
||||
scale = 1,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
opacity = 1,
|
||||
children,
|
||||
}: SimpleModelProps): React.JSX.Element {
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
@@ -48,6 +68,10 @@ export function SimpleModel({
|
||||
applyShadowSettings(model, castShadow, receiveShadow);
|
||||
}, [castShadow, model, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
applyOpacity(model, opacity);
|
||||
}, [model, opacity]);
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
||||
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||
import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
|
||||
<RepairMovementLockIndicator />
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<HandTrackingFallback />
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<GameSettingsMenu />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import {
|
||||
useHandTrackingGloveStatus,
|
||||
type HandTrackingGloveHandedness,
|
||||
} from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
|
||||
// Simple schematic silhouettes used as a last-resort fallback when the
|
||||
// rigged glove model has failed to load. Both icons share the same
|
||||
// 48x48 viewBox and the same stroke/fill rules from the .css.
|
||||
|
||||
const OpenHandShape = (): React.JSX.Element => (
|
||||
<>
|
||||
<ellipse cx="9" cy="30" rx="3" ry="6" transform="rotate(-25 9 30)" />
|
||||
<rect x="14" y="8" width="4" height="22" rx="2" />
|
||||
<rect x="20" y="4" width="4" height="26" rx="2" />
|
||||
<rect x="26" y="6" width="4" height="24" rx="2" />
|
||||
<rect x="32" y="10" width="4" height="20" rx="2" />
|
||||
<rect x="10" y="26" width="28" height="18" rx="6" />
|
||||
</>
|
||||
);
|
||||
|
||||
const FistShape = (): React.JSX.Element => (
|
||||
<>
|
||||
<ellipse cx="8" cy="26" rx="3" ry="5" />
|
||||
<rect x="10" y="14" width="28" height="30" rx="10" />
|
||||
<circle cx="15" cy="14" r="3" />
|
||||
<circle cx="21" cy="13" r="3" />
|
||||
<circle cx="27" cy="13" r="3" />
|
||||
<circle cx="33" cy="14" r="3" />
|
||||
</>
|
||||
);
|
||||
|
||||
function getHandedness(raw: string): HandTrackingGloveHandedness | null {
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower === "left" || lower === "right") return lower;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HandTrackingFallback(): React.JSX.Element | null {
|
||||
const { hands } = useHandTrackingSnapshot();
|
||||
const gloveStatus = useHandTrackingGloveStatus((state) => state.gloves);
|
||||
|
||||
const visibleHands = hands.flatMap((hand, index) => {
|
||||
const handedness = getHandedness(hand.handedness);
|
||||
if (!handedness) return [];
|
||||
if (gloveStatus[handedness] !== "error") return [];
|
||||
|
||||
const wrist = hand.landmarks[0];
|
||||
if (!wrist) return [];
|
||||
|
||||
return [{ hand, handedness, wrist, index }];
|
||||
});
|
||||
|
||||
if (visibleHands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="hand-tracking-fallback" aria-hidden="true">
|
||||
{visibleHands.map(({ hand, handedness, wrist, index }) => {
|
||||
// MediaPipe coords are mirrored (selfie cam), keep the same
|
||||
// mapping the SVG visualizer uses.
|
||||
const leftPercent = (1 - wrist.x) * 100;
|
||||
const topPercent = wrist.y * 100;
|
||||
const flipX = handedness === "right" ? -1 : 1;
|
||||
|
||||
return (
|
||||
<svg
|
||||
key={`${handedness}-${index}`}
|
||||
className="hand-tracking-fallback__icon"
|
||||
viewBox="0 0 48 48"
|
||||
style={{
|
||||
left: `${leftPercent}%`,
|
||||
top: `${topPercent}%`,
|
||||
transform: `translate(-50%, -50%) scaleX(${flipX})`,
|
||||
}}
|
||||
>
|
||||
{hand.isFist ? <FistShape /> : <OpenHandShape />}
|
||||
</svg>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,12 @@ const HAND_CONNECTIONS: Array<[number, number]> = [
|
||||
[0, 17],
|
||||
];
|
||||
|
||||
const LANDMARK_FILL = "#67e8f9"; // cyan-300, opaque interior
|
||||
const LANDMARK_STROKE = "#0c4a6e"; // sky-900, dark blue outline
|
||||
const LANDMARK_STROKE_FIST = "#1e3a8a"; // blue-900, thicker accent when fist
|
||||
const CONNECTION_STROKE = "#ffffff"; // white bones
|
||||
const INDEX_TIP_LANDMARK = 8;
|
||||
|
||||
export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
const { hands, status } = useHandTrackingSnapshot();
|
||||
const showHandTrackingSvg = useDebugStore((debug) =>
|
||||
@@ -50,7 +56,9 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) return null;
|
||||
|
||||
const color = hand.isFist ? "#facc15" : "#38bdf8";
|
||||
const landmarkStroke = hand.isFist
|
||||
? LANDMARK_STROKE_FIST
|
||||
: LANDMARK_STROKE;
|
||||
|
||||
return (
|
||||
<g key={`${hand.handedness}-${handIndex}`}>
|
||||
@@ -66,8 +74,8 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
y1={`${fromPoint.y * 100}%`}
|
||||
x2={`${(1 - toPoint.x) * 100}%`}
|
||||
y2={`${toPoint.y * 100}%`}
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
stroke={CONNECTION_STROKE}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
@@ -78,8 +86,10 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
key={landmarkIndex}
|
||||
cx={`${(1 - landmark.x) * 100}%`}
|
||||
cy={`${landmark.y * 100}%`}
|
||||
r={landmarkIndex === 8 ? 5 : 3}
|
||||
fill={landmarkIndex === 8 ? "#ffffff" : color}
|
||||
r={landmarkIndex === INDEX_TIP_LANDMARK ? 6 : 4}
|
||||
fill={LANDMARK_FILL}
|
||||
stroke={landmarkStroke}
|
||||
strokeWidth={hand.isFist ? 2.5 : 2}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
Reference in New Issue
Block a user