feat(repair): support multiple required parts and per-part case anchor
- RepairMissionConfig.requiredReplacementPartId (string) is replaced by requiredReplacementPartIds (readonly string[]) so a mission can accept several alternative correct parts (e.g. pylon will accept either cable). - RepairMissionPartConfig gains optional caseAnchor (where the standalone spawns inside packderelance), caseLockGroup (mutually exclusive parts), and targetNodeName (snap onto a node of the broken model rather than a placeholder slot in the case). - RepairScannedBrokenPart gains targetNodeName so scan results can carry this hint through to the repairing step. - RepairRepairingStep validation logic (placed/wrong/feedback) now matches any id in requiredReplacementPartIds. Existing data is migrated mechanically (single-element arrays); part-level new fields are wired in subsequent commits.
This commit is contained in:
@@ -80,8 +80,8 @@ export function RepairRepairingStep({
|
||||
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 +89,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
|
||||
@@ -198,7 +199,7 @@ export function RepairRepairingStep({
|
||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||
const feedbackState = getReplacementFeedbackState(
|
||||
part.id,
|
||||
config.requiredReplacementPartId,
|
||||
config.requiredReplacementPartIds,
|
||||
isPlaced,
|
||||
);
|
||||
|
||||
@@ -387,12 +388,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 +467,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,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
requiredReplacementPartId: "ebike-cooling-core-replacement",
|
||||
requiredReplacementPartIds: ["ebike-cooling-core-replacement"],
|
||||
brokenParts: [
|
||||
{
|
||||
id: "ebike-cooling-core",
|
||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.8,
|
||||
requiredReplacementPartId: "pylon-grid-relay-replacement",
|
||||
requiredReplacementPartIds: ["pylon-grid-relay-replacement"],
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [
|
||||
{
|
||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
reassemblySeconds: 1.2,
|
||||
requiredReplacementPartId: "farm-irrigation-pump-replacement",
|
||||
requiredReplacementPartIds: ["farm-irrigation-pump-replacement"],
|
||||
scanPartSeconds: 0.9,
|
||||
brokenParts: [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Vector3Scale,
|
||||
Vector3Tuple,
|
||||
} from "@/types/three/three";
|
||||
import type { RepairCasePartAnchorName } from "@/data/gameplay/repairCaseConfig";
|
||||
|
||||
export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
|
||||
|
||||
@@ -24,7 +25,28 @@ export interface RepairMissionPartConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeName?: string;
|
||||
/**
|
||||
* Name of a node inside the broken model where this part should snap on
|
||||
* install. Used by replacement parts that target a slot in the broken
|
||||
* model itself (e.g. pylon cable installs at the world-position of the
|
||||
* pylon's `cable2` node), and by broken parts that should spawn at their
|
||||
* original location on the broken model rather than a static offset.
|
||||
*/
|
||||
targetNodeName?: string;
|
||||
caseSlotName?: string;
|
||||
/**
|
||||
* Anchor name in the packderelance case where this replacement part is
|
||||
* visually injected. When set, the part spawns at the world-position of
|
||||
* that anchor instead of a generic placeholder slot.
|
||||
*/
|
||||
caseAnchor?: RepairCasePartAnchorName;
|
||||
/**
|
||||
* Group identifier for mutually exclusive replacement parts (e.g. pylon
|
||||
* cables: only one cable can be held/installed at a time). When one part
|
||||
* of the group is held, others in the same group are visually ghosted
|
||||
* and non-interactive.
|
||||
*/
|
||||
caseLockGroup?: string;
|
||||
modelPath?: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +55,7 @@ export interface RepairScannedBrokenPart {
|
||||
label: string;
|
||||
modelPath: string;
|
||||
caseSlotName?: string;
|
||||
targetNodeName?: string;
|
||||
}
|
||||
|
||||
export interface RepairMissionConfig {
|
||||
@@ -46,7 +69,13 @@ export interface RepairMissionConfig {
|
||||
brokenUiPath: string;
|
||||
case: RepairMissionCaseConfig;
|
||||
reassemblySeconds?: number;
|
||||
requiredReplacementPartId: string;
|
||||
/**
|
||||
* Replacement part IDs accepted as the correct install. Multiple values
|
||||
* are used when several alternatives are valid (e.g. pylon accepts either
|
||||
* cable model). Install validation succeeds when any one of these parts
|
||||
* is snapped into a placeholder slot.
|
||||
*/
|
||||
requiredReplacementPartIds: readonly string[];
|
||||
scanPartSeconds?: number;
|
||||
brokenParts: readonly RepairMissionPartConfig[];
|
||||
replacementParts: readonly RepairMissionPartConfig[];
|
||||
|
||||
Reference in New Issue
Block a user