Merge branch 'develop' into feat/shader-net
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
math-pixel
2026-05-27 18:08:46 +02:00
233 changed files with 34339 additions and 3142 deletions
+99 -7
View File
@@ -1,34 +1,126 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useRef } from "react";
import { Component, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
modelPath: string;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
scale?: number | undefined;
}
interface SkyModelContentProps {
modelPath: string;
scale: number;
}
interface SkyModelErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
}
interface SkyModelErrorBoundaryState {
hasError: boolean;
}
const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000;
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
class SkyModelErrorBoundary extends Component<
SkyModelErrorBoundaryProps,
SkyModelErrorBoundaryState
> {
constructor(props: SkyModelErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): SkyModelErrorBoundaryState {
return { hasError: true };
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export function SkyModel({
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
modelPath,
scale = SKY_MODEL_SCALE,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? (
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
) : null;
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelContent modelPath={modelPath} scale={scale} />
</SkyModelErrorBoundary>
);
}
function SkyModelContent({
modelPath,
scale,
}: SkyModelContentProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const { scene } = useLoggedGLTF(modelPath, {
scope: "SkyModel",
scale: SKY_MODEL_SCALE,
scale,
});
const model = useClonedObject(scene);
const model = useMemo(() => createSkyModel(scene), [scene]);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
});
return (
<group ref={groupRef} scale={SKY_MODEL_SCALE} frustumCulled={false}>
<group
ref={groupRef}
renderOrder={SKY_MODEL_RENDER_ORDER}
scale={scale}
frustumCulled={false}
>
<primitive object={model} />
</group>
);
}
useGLTF.preload("/models/sky/model.glb");
function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
const model = scene.clone(true);
model.traverse((object) => {
object.frustumCulled = false;
object.renderOrder = SKY_MODEL_RENDER_ORDER;
if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material)
? object.material.map(createSkyMaterial)
: createSkyMaterial(object.material);
});
return model;
}
function createSkyMaterial<T extends THREE.Material>(material: T): T {
const skyMaterial = material.clone();
skyMaterial.side = THREE.BackSide;
skyMaterial.depthTest = false;
skyMaterial.depthWrite = false;
return skyMaterial as T;
}
useGLTF.preload("/models/skybox/skybox.gltf");
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
+1 -1
View File
@@ -52,7 +52,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf",
modelScale: 0.50,
modelScale: 0.5,
stageUiPath: "/assets/UI/ebike.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
+4 -1
View File
@@ -1,2 +1,5 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/skybox.gltf";
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 300;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+1
View File
@@ -54,6 +54,7 @@ const docsChildRoutes = [
{ path: "architecture", component: DocsArchitectureRoute },
{ path: "scene-runtime", component: DocsSceneRuntimeRoute },
{ path: "repair-game", component: DocsRepairGameRoute },
{ path: "mission-flow", component: DocsMissionFlowRoute },
{ path: "interaction", component: DocsInteractionRoute },
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
+8 -1
View File
@@ -4,6 +4,7 @@ import { parseMapNodes } from "@/utils/map/mapNodeValidation";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
const HTML_CONTENT_TYPE = "text/html";
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
type ModelEntry = [modelName: string, modelUrl: string];
export async function loadMapSceneData(): Promise<SceneData | null> {
@@ -26,7 +27,13 @@ async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> {
async function loadMapModelUrls(
mapNodes: MapNode[],
): Promise<Map<string, string>> {
const uniqueModelNames = [...new Set(mapNodes.map((node) => node.name))];
const uniqueModelNames = [
...new Set(
mapNodes
.filter((node) => !MAP_STRUCTURE_NODE_NAMES.has(node.name))
.map((node) => node.name),
),
];
const modelEntries = await Promise.all(
uniqueModelNames.map((modelName) => loadModelEntry(modelName)),
);
+11 -1
View File
@@ -1,5 +1,8 @@
import {
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE,
PHYSICS_SCENE_BACKGROUND_COLOR,
} from "@/data/world/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
@@ -14,5 +17,12 @@ export function Environment(): React.JSX.Element {
);
}
return <SkyModel modelPath={GAME_SCENE_SKY_MODEL_PATH} />;
return (
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
/>
);
}
+41 -1
View File
@@ -22,6 +22,16 @@ interface LoadedMapNode {
modelUrl: string | null;
}
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
const LITE_MAP_SKIPPED_NODE_NAMES = new Set([
"arbre",
"buissons",
"champdeble",
"champdesoja",
"champsdetournesol",
"sapin",
]);
interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
@@ -133,7 +143,17 @@ export function GameMap({
status: "loading",
});
const loadedMapNodes = sceneData.mapNodes.map((node) => {
const visibleMapNodes = sceneData.mapNodes.filter(liteMap);
const skippedMapNodeCount =
sceneData.mapNodes.length - visibleMapNodes.length;
if (skippedMapNodeCount > 0) {
logger.warn("GameMap", "Lite map skipped heavy map nodes", {
skippedMapNodeCount,
});
}
const loadedMapNodes = visibleMapNodes.map((node) => {
const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null };
});
@@ -214,6 +234,26 @@ export function GameMap({
);
}
/**
* Temporary development-only map reducer.
*
* TODO: replace this with a real map performance pass: merged static geometry,
* instancing for repeated props, LOD, and/or zone-based loading. For now this
* keeps the app usable on local machines by not rendering the densest exported
* nodes from map.json.
*/
function liteMap(node: MapNode): boolean {
if (MAP_STRUCTURE_NODE_NAMES.has(node.name)) {
return false;
}
if (node.type === "Mesh") {
return false;
}
return !LITE_MAP_SKIPPED_NODE_NAMES.has(node.name);
}
function MapNodeInstance({
node,
modelUrl,
+2 -1
View File
@@ -106,7 +106,8 @@ export function PlayerController({
const velocity = useRef(new THREE.Vector3());
const onFloor = useRef(false);
const wantsJump = useRef(false);
const initializedRef = useRef(false); const canMove = useGameStore((state) => state.missionFlow.canMove);
const initializedRef = useRef(false);
const canMove = useGameStore((state) => state.missionFlow.canMove);
const capsule = useRef(createSpawnCapsule(spawnPosition));