Feat/repair game #2

Merged
math-pixel merged 46 commits from feat/repair-game into develop 2026-05-11 15:33:19 +00:00
4 changed files with 112 additions and 18 deletions
Showing only changes of commit f9340ae57d - Show all commits
@@ -71,6 +71,7 @@ export function RepairGame({
useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation,
keyboardEnabled: false,
onFragment: () => setMissionStep(mission, "fragmented"),
});
@@ -143,6 +144,11 @@ export function RepairGame({
open={step === "repairing"}
zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation}
onInteract={
readyForFragmentation
? () => setMissionStep(mission, "fragmented")
: undefined
}
/>
) : null}
</Suspense>
@@ -3,12 +3,14 @@ import {
type RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
REPAIR_CASE_FOCUS_POSITION,
REPAIR_CASE_FOCUS_SCALE,
REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps {
config: RepairMissionConfig;
@@ -20,6 +22,7 @@ interface RepairMissionCaseProps {
open?: boolean;
zoomed?: boolean;
showFragmentationPrompt?: boolean;
onInteract?: (() => void) | undefined;
}
export function RepairMissionCase({
@@ -30,25 +33,48 @@ export function RepairMissionCase({
open = false,
zoomed = false,
showFragmentationPrompt = false,
onInteract,
}: RepairMissionCaseProps): React.JSX.Element {
const casePosition = zoomed
? REPAIR_CASE_FOCUS_POSITION
: config.case.position;
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
return (
<group>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={casePosition}
rotation={config.case.rotation}
scale={caseScale}
/>
{onInteract ? (
<TriggerObject
position={casePosition}
colliders="ball"
label={`Ouvrir ${config.label}`}
onTrigger={onInteract}
>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={modelPosition}
rotation={config.case.rotation}
scale={caseScale}
/>
</TriggerObject>
) : (
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={modelPosition}
rotation={config.case.rotation}
scale={caseScale}
/>
)}
{showFragmentationPrompt && !exiting ? (
<RepairPromptVideo
src={config.interactUiPath}
@@ -30,6 +30,9 @@ const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
[1.35, 0.55, -0.85],
];
const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8";
interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[];
@@ -50,6 +53,10 @@ interface RepairPlaceholderMarkersProps {
positions: readonly Vector3Tuple[];
}
interface RepairPartPlacementFeedbackProps {
state: "valid" | "invalid" | "stored" | null;
}
export function RepairRepairingStep({
brokenParts,
config,
@@ -167,6 +174,12 @@ export function RepairRepairingStep({
const placeholderPosition =
placeholderPositions[index % placeholderPositions.length] ??
placeholderPositions[0]!;
const isPlaced = Boolean(placedPartIds[part.id]);
const feedbackState = getReplacementFeedbackState(
part.id,
config.requiredReplacementPartId,
isPlaced,
);
return (
<GrabbableObject
@@ -185,11 +198,14 @@ export function RepairRepairingStep({
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={placeholderPositions}
>
<RepairObjectModel
label={part.label}
modelPath={part.modelPath ?? config.modelPath}
scale={0.36}
/>
<group>
<RepairObjectModel
label={part.label}
modelPath={part.modelPath ?? config.modelPath}
scale={0.36}
/>
<RepairPartPlacementFeedback state={feedbackState} />
</group>
</GrabbableObject>
);
})}
@@ -207,6 +223,7 @@ export function RepairRepairingStep({
part,
placeholderTargets,
);
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
return (
<GrabbableObject
@@ -235,6 +252,9 @@ export function RepairRepairingStep({
<sphereGeometry args={[0.11, 16, 16]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
</mesh>
<RepairPartPlacementFeedback
state={isDeposited ? "stored" : null}
/>
</group>
</GrabbableObject>
);
@@ -296,6 +316,46 @@ function RepairPlaceholderMarkers({
);
}
function RepairPartPlacementFeedback({
state,
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
if (!state) return null;
const color = getPlacementFeedbackColor(state);
return (
<group position={[0, 0.72, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.48, 0.035, 12, 64]} />
<meshBasicMaterial color={color} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.08, 0]}>
<sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial color={color} transparent opacity={0.9} />
</mesh>
</group>
);
}
function getPlacementFeedbackColor(
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
): string {
if (state === "valid") return VALID_PART_COLOR;
if (state === "stored") return STORED_BROKEN_PART_COLOR;
return INVALID_PART_COLOR;
}
function getReplacementFeedbackState(
partId: string,
requiredPartId: string,
isPlaced: boolean,
): RepairPartPlacementFeedbackProps["state"] {
if (!isPlaced) return null;
return partId === requiredPartId ? "valid" : "invalid";
}
function getPlaceholderTargets(
placeholders: readonly RepairCasePlaceholder[],
): readonly RepairCasePlaceholder[] {
@@ -5,11 +5,13 @@ import { useBothFistsHold } from "@/hooks/handTracking/useBothFistsHold";
interface UseRepairFragmentationInputOptions {
enabled: boolean;
keyboardEnabled?: boolean;
onFragment: () => void;
}
export function useRepairFragmentationInput({
enabled,
keyboardEnabled = true,
onFragment,
}: UseRepairFragmentationInputOptions): void {
const completedRef = useRef(false);
@@ -29,7 +31,7 @@ export function useRepairFragmentationInput({
}, [enabled, onFragment]);
useEffect(() => {
if (!enabled) return undefined;
if (!enabled || !keyboardEnabled) return undefined;
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key.toLowerCase() !== INTERACT_KEY) return;
@@ -43,7 +45,7 @@ export function useRepairFragmentationInput({
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [enabled, fragment]);
}, [enabled, fragment, keyboardEnabled]);
useBothFistsHold({
enabled,