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);
|
useState(false);
|
||||||
const replacementParts = getReplacementParts(config);
|
const replacementParts = getReplacementParts(config);
|
||||||
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
||||||
const requiredReplacementPart = replacementParts.find(
|
const requiredReplacementPart = replacementParts.find((part) =>
|
||||||
(part) => part.id === config.requiredReplacementPartId,
|
config.requiredReplacementPartIds.includes(part.id),
|
||||||
);
|
);
|
||||||
const requiredReplacementLabel =
|
const requiredReplacementLabel =
|
||||||
requiredReplacementPart?.label ?? config.label;
|
requiredReplacementPart?.label ?? config.label;
|
||||||
@@ -89,15 +89,16 @@ export function RepairRepairingStep({
|
|||||||
const placeholderPositions = placeholderTargets.map(
|
const placeholderPositions = placeholderTargets.map(
|
||||||
(target) => target.position,
|
(target) => target.position,
|
||||||
);
|
);
|
||||||
const hasCorrectPartPlaced = Boolean(
|
const hasCorrectPartPlaced = config.requiredReplacementPartIds.some(
|
||||||
placedPartIds[config.requiredReplacementPartId],
|
(id) => placedPartIds[id],
|
||||||
);
|
);
|
||||||
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
||||||
(part) => depositedBrokenPartIds[part.id],
|
(part) => depositedBrokenPartIds[part.id],
|
||||||
);
|
);
|
||||||
const hasWrongPartPlaced = replacementParts.some(
|
const hasWrongPartPlaced = replacementParts.some(
|
||||||
(part) =>
|
(part) =>
|
||||||
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
|
!config.requiredReplacementPartIds.includes(part.id) &&
|
||||||
|
placedPartIds[part.id],
|
||||||
);
|
);
|
||||||
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
||||||
const installColor = isReadyToInstall
|
const installColor = isReadyToInstall
|
||||||
@@ -198,7 +199,7 @@ export function RepairRepairingStep({
|
|||||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||||
const feedbackState = getReplacementFeedbackState(
|
const feedbackState = getReplacementFeedbackState(
|
||||||
part.id,
|
part.id,
|
||||||
config.requiredReplacementPartId,
|
config.requiredReplacementPartIds,
|
||||||
isPlaced,
|
isPlaced,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -387,12 +388,12 @@ function getPlacementFeedbackColor(
|
|||||||
|
|
||||||
function getReplacementFeedbackState(
|
function getReplacementFeedbackState(
|
||||||
partId: string,
|
partId: string,
|
||||||
requiredPartId: string,
|
requiredPartIds: readonly string[],
|
||||||
isPlaced: boolean,
|
isPlaced: boolean,
|
||||||
): RepairPartPlacementFeedbackProps["state"] {
|
): RepairPartPlacementFeedbackProps["state"] {
|
||||||
if (!isPlaced) return null;
|
if (!isPlaced) return null;
|
||||||
|
|
||||||
return partId === requiredPartId ? "valid" : "invalid";
|
return requiredPartIds.includes(partId) ? "valid" : "invalid";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlaceholderTargets(
|
function getPlaceholderTargets(
|
||||||
@@ -466,9 +467,12 @@ function getReplacementParts(
|
|||||||
): readonly RepairMissionPartConfig[] {
|
): readonly RepairMissionPartConfig[] {
|
||||||
if (config.replacementParts.length > 0) return config.replacementParts;
|
if (config.replacementParts.length > 0) return config.replacementParts;
|
||||||
|
|
||||||
|
const fallbackId =
|
||||||
|
config.requiredReplacementPartIds[0] ?? `${config.id}-replacement`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: config.requiredReplacementPartId,
|
id: fallbackId,
|
||||||
label: config.label,
|
label: config.label,
|
||||||
modelPath: config.modelPath,
|
modelPath: config.modelPath,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
requiredReplacementPartId: "ebike-cooling-core-replacement",
|
requiredReplacementPartIds: ["ebike-cooling-core-replacement"],
|
||||||
brokenParts: [
|
brokenParts: [
|
||||||
{
|
{
|
||||||
id: "ebike-cooling-core",
|
id: "ebike-cooling-core",
|
||||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
reassemblySeconds: 1.8,
|
reassemblySeconds: 1.8,
|
||||||
requiredReplacementPartId: "pylon-grid-relay-replacement",
|
requiredReplacementPartIds: ["pylon-grid-relay-replacement"],
|
||||||
scanPartSeconds: 1.4,
|
scanPartSeconds: 1.4,
|
||||||
brokenParts: [
|
brokenParts: [
|
||||||
{
|
{
|
||||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
reassemblySeconds: 1.2,
|
reassemblySeconds: 1.2,
|
||||||
requiredReplacementPartId: "farm-irrigation-pump-replacement",
|
requiredReplacementPartIds: ["farm-irrigation-pump-replacement"],
|
||||||
scanPartSeconds: 0.9,
|
scanPartSeconds: 0.9,
|
||||||
brokenParts: [
|
brokenParts: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Vector3Scale,
|
Vector3Scale,
|
||||||
Vector3Tuple,
|
Vector3Tuple,
|
||||||
} from "@/types/three/three";
|
} from "@/types/three/three";
|
||||||
|
import type { RepairCasePartAnchorName } from "@/data/gameplay/repairCaseConfig";
|
||||||
|
|
||||||
export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
|
export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
|
||||||
|
|
||||||
@@ -24,7 +25,28 @@ export interface RepairMissionPartConfig {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
nodeName?: 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;
|
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;
|
modelPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +55,7 @@ export interface RepairScannedBrokenPart {
|
|||||||
label: string;
|
label: string;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
caseSlotName?: string;
|
caseSlotName?: string;
|
||||||
|
targetNodeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepairMissionConfig {
|
export interface RepairMissionConfig {
|
||||||
@@ -46,7 +69,13 @@ export interface RepairMissionConfig {
|
|||||||
brokenUiPath: string;
|
brokenUiPath: string;
|
||||||
case: RepairMissionCaseConfig;
|
case: RepairMissionCaseConfig;
|
||||||
reassemblySeconds?: number;
|
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;
|
scanPartSeconds?: number;
|
||||||
brokenParts: readonly RepairMissionPartConfig[];
|
brokenParts: readonly RepairMissionPartConfig[];
|
||||||
replacementParts: readonly RepairMissionPartConfig[];
|
replacementParts: readonly RepairMissionPartConfig[];
|
||||||
|
|||||||
Reference in New Issue
Block a user