fix(repair): drill explosion to natural group + apply mission rotation

- ExplodedModel.createParts now descends recursively through single
  mesh-bearing wrapper nodes (e.g. Scene > Moto > Eclatement) until
  reaching a node with multiple mesh-bearing children. Previously the
  first wrapper was used as root, so models with extra Empty/group
  parents fell back to flat leaf meshes lerping in local space.
- Add optional modelRotation field on RepairMissionConfig so fragmented
  + repairing models can match the world-space rotation of the source
  inspection model (parked Ebike).
- Ebike mission now uses EBIKE_WORLD_ROTATION_Y/EBIKE_WORLD_SCALE
  directly so the fragmented bike lines up with the parked bike.
This commit is contained in:
Tom Boullay
2026-06-02 22:51:35 +02:00
parent 89050331df
commit d9a92e336c
4 changed files with 31 additions and 7 deletions
@@ -143,6 +143,7 @@ export function RepairGame({
{step === "fragmented" ? ( {step === "fragmented" ? (
<ExplodableModel <ExplodableModel
modelPath={config.modelPath} modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]}
scale={config.modelScale ?? 1} scale={config.modelScale ?? 1}
split split
/> />
@@ -160,6 +161,7 @@ export function RepairGame({
<> <>
<ExplodableModel <ExplodableModel
modelPath={config.modelPath} modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]}
scale={config.modelScale ?? 1} scale={config.modelScale ?? 1}
split split
hideNodeNames={brokenNodeNames} hideNodeNames={brokenNodeNames}
+6 -1
View File
@@ -3,6 +3,10 @@ import type {
RepairMissionConfig, RepairMissionConfig,
RepairMissionId, RepairMissionId,
} from "@/types/gameplay/repairMission"; } from "@/types/gameplay/repairMission";
import {
EBIKE_WORLD_ROTATION_Y,
EBIKE_WORLD_SCALE,
} from "@/data/ebike/ebikeConfig";
const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm"; const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm";
const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm"; const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm";
@@ -20,7 +24,8 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description: description:
"Repair the damaged cooling module before relaunching the bike", "Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf", modelPath: "/models/ebike/model.gltf",
modelScale: 0.3, modelScale: EBIKE_WORLD_SCALE,
modelRotation: [0, EBIKE_WORLD_ROTATION_Y, 0],
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm", stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH, interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH,
+7
View File
@@ -64,6 +64,13 @@ export interface RepairMissionConfig {
description: string; description: string;
modelPath: string; modelPath: string;
modelScale?: ModelTransformProps["scale"]; modelScale?: ModelTransformProps["scale"];
/**
* World-space rotation applied to the model when mounted by RepairGame
* (fragmented + repairing steps). Should match the rotation used by the
* source object in the world (e.g. parked Ebike) so the fragmented model
* lines up visually with the inspection model.
*/
modelRotation?: Vector3Tuple;
stageUiPath: string; stageUiPath: string;
interactUiPath: string; interactUiPath: string;
brokenUiPath: string; brokenUiPath: string;
+16 -6
View File
@@ -53,13 +53,23 @@ export class ExplodedModel {
} }
private createParts(model: THREE.Object3D): ExplodedPart[] { private createParts(model: THREE.Object3D): ExplodedPart[] {
const root = // Drill down through single-mesh-bearing branches until we find a node
model.children.length === 1 && model.children[0] // with multiple mesh-bearing children (the natural "explosion group" the
? model.children[0] // modeler authored). Falls back to flat mesh list only if no such group
: model; // exists. This avoids exploding leaves in local space when wrapper nodes
const directChildren = root.children.filter((child) => hasMesh(child)); // (e.g. "Empty" + "Moto" > "Eclatement") sit above the actual group.
let current = model;
while (true) {
const meshChildren = current.children.filter((child) => hasMesh(child));
if (meshChildren.length === 1 && meshChildren[0]) {
current = meshChildren[0];
continue;
}
break;
}
const directChildren = current.children.filter((child) => hasMesh(child));
const sourceObjects = const sourceObjects =
directChildren.length > 1 ? directChildren : getMeshes(root); directChildren.length > 1 ? directChildren : getMeshes(current);
if (sourceObjects.length === 0) return []; if (sourceObjects.length === 0) return [];