Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 439f9c1dad | |||
| 260bfea716 | |||
| b3a3f3557c | |||
| 621556b38c | |||
| 5c55f2c7f4 | |||
| bb4bccb175 | |||
| ae835e5008 | |||
| 1d3aa1c9f2 | |||
| 23cab2da5e | |||
| 44216f9395 |
@@ -1,59 +1,58 @@
|
||||
name: 🔁 Weekly Branch Promotions
|
||||
name: 🔁 Branch Promotions
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
- cron: "0 6 * * 1,4" # Lundi et Jeudi à 6h UTC (design → develop)
|
||||
- cron: "0 6 * * 1" # Lundi à 6h UTC (develop → main)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
promotion:
|
||||
description: "Which promotion to run"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- design-to-develop
|
||||
- develop-to-main
|
||||
- both
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: weekly-branch-promotions
|
||||
group: branch-promotions
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
open-promotion-pr:
|
||||
name: Open ${{ matrix.head }} → ${{ matrix.base }}
|
||||
design-to-develop:
|
||||
name: Open design → develop
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- head: develop
|
||||
base: design
|
||||
title: "chore: merge develop into design"
|
||||
- head: design
|
||||
base: main
|
||||
title: "chore: merge design into main"
|
||||
|
||||
if: |
|
||||
(github.event_name == 'schedule') ||
|
||||
(github.event_name == 'workflow_dispatch' && (github.event.inputs.promotion == 'design-to-develop' || github.event.inputs.promotion == 'both'))
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🔁 Open promotion PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_BRANCH: ${{ matrix.base }}
|
||||
HEAD_BRANCH: ${{ matrix.head }}
|
||||
PR_TITLE: ${{ matrix.title }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin "$BASE_BRANCH" "$HEAD_BRANCH"
|
||||
git fetch origin develop design
|
||||
|
||||
if git merge-base --is-ancestor "origin/$HEAD_BRANCH" "origin/$BASE_BRANCH"; then
|
||||
echo "No promotion needed: $BASE_BRANCH already contains $HEAD_BRANCH."
|
||||
if git merge-base --is-ancestor origin/design origin/develop; then
|
||||
echo "No promotion needed: develop already contains design."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
existing_pr="$(gh pr list \
|
||||
--state open \
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$GITHUB_REPOSITORY_OWNER:$HEAD_BRANCH" \
|
||||
--base develop \
|
||||
--head "$GITHUB_REPOSITORY_OWNER:design" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')"
|
||||
|
||||
@@ -63,7 +62,50 @@ jobs:
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$HEAD_BRANCH" \
|
||||
--title "$PR_TITLE" \
|
||||
--body "Automated weekly promotion PR from \`$HEAD_BRANCH\` to \`$BASE_BRANCH\`."
|
||||
--base develop \
|
||||
--head design \
|
||||
--title "chore: merge design into develop" \
|
||||
--body "Automated promotion PR from \`design\` to \`develop\`."
|
||||
|
||||
develop-to-main:
|
||||
name: Open develop → main
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event_name == 'schedule' && github.event.schedule == '0 6 * * 1') ||
|
||||
(github.event_name == 'workflow_dispatch' && (github.event.inputs.promotion == 'develop-to-main' || github.event.inputs.promotion == 'both'))
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🔁 Open promotion PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin main develop
|
||||
|
||||
if git merge-base --is-ancestor origin/develop origin/main; then
|
||||
echo "No promotion needed: main already contains develop."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
existing_pr="$(gh pr list \
|
||||
--state open \
|
||||
--base main \
|
||||
--head "$GITHUB_REPOSITORY_OWNER:develop" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')"
|
||||
|
||||
if [ -n "$existing_pr" ]; then
|
||||
echo "Promotion PR already open: #$existing_pr."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head develop \
|
||||
--title "chore: merge develop into main" \
|
||||
--body "Automated weekly promotion PR from \`develop\` to \`main\`."
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
export interface SimpleModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
@@ -29,6 +30,12 @@ export function SimpleModel({
|
||||
});
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
const parsedScale =
|
||||
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Component, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
interface SkyModelProps {
|
||||
modelPath: string;
|
||||
@@ -80,6 +81,12 @@ function SkyModelContent({
|
||||
});
|
||||
const model = useMemo(() => createSkyModel(scene), [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
useFrame(() => {
|
||||
groupRef.current?.position.copy(camera.position);
|
||||
});
|
||||
@@ -122,5 +129,5 @@ function createSkyMaterial<T extends THREE.Material>(material: T): T {
|
||||
return skyMaterial as T;
|
||||
}
|
||||
|
||||
useGLTF.preload("/models/skybox/skybox.gltf");
|
||||
useGLTF.preload("/models/skybox/model.gltf");
|
||||
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
interface TerrainModelProps {
|
||||
position?: Vector3Tuple;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: Vector3Tuple;
|
||||
receiveShadow?: boolean;
|
||||
visible?: boolean;
|
||||
groupRef?: React.RefObject<THREE.Group>;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
function applyTerrainMaterialSettings(
|
||||
scene: THREE.Object3D,
|
||||
receiveShadow: boolean,
|
||||
): void {
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.receiveShadow = receiveShadow;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function TerrainModel({
|
||||
position = TERRAIN_DEFAULT_POSITION,
|
||||
rotation = [0, 0, 0],
|
||||
scale = [1, 1, 1],
|
||||
receiveShadow = true,
|
||||
visible = true,
|
||||
groupRef,
|
||||
onLoaded,
|
||||
}: TerrainModelProps): React.JSX.Element {
|
||||
const internalRef = useRef<THREE.Group>(null);
|
||||
const ref = groupRef ?? internalRef;
|
||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||
|
||||
const terrainModel = useMemo(() => {
|
||||
const model = scene.clone(true);
|
||||
applyTerrainMaterialSettings(model, receiveShadow);
|
||||
return model;
|
||||
}, [scene, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(terrainModel);
|
||||
};
|
||||
}, [terrainModel]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded?.();
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={ref}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
visible={visible}
|
||||
>
|
||||
<primitive object={terrainModel} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(TERRAIN_MODEL_PATH);
|
||||
@@ -1,5 +1,5 @@
|
||||
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_SKY_MODEL_SCALE = 100;
|
||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
|
||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
||||
|
||||
export const FOG_CONFIG = {
|
||||
enabled: true,
|
||||
color: "#c8dbbe",
|
||||
near: 50,
|
||||
far: 70,
|
||||
};
|
||||
|
||||
export const CHUNK_CONFIG = {
|
||||
enabled: true,
|
||||
chunkSize: 40,
|
||||
loadRadius: 70,
|
||||
unloadRadius: 80,
|
||||
updateInterval: 500,
|
||||
};
|
||||
|
||||
export const GROUND_PLANE_COLOR = TERRAIN_COLORS.grass1.hex;
|
||||
@@ -0,0 +1,13 @@
|
||||
export const GRAPHICS_DEFAULTS = {
|
||||
dynamicGrass: true,
|
||||
dynamicTrees: true,
|
||||
dynamicClouds: true,
|
||||
shadowsEnabled: true,
|
||||
grassDensity: 1.0,
|
||||
};
|
||||
|
||||
export const GRAPHICS_BOUNDS = {
|
||||
grassDensity: { min: 0.1, max: 2.0, step: 0.1 },
|
||||
};
|
||||
|
||||
export type GraphicsState = typeof GRAPHICS_DEFAULTS;
|
||||
@@ -0,0 +1,108 @@
|
||||
export const TERRAIN_COLORS = {
|
||||
grass1: {
|
||||
hex: "#84C66B",
|
||||
rgb: [132, 198, 107] as const,
|
||||
type: "grass" as const,
|
||||
grassTipColor: "#84C66B",
|
||||
},
|
||||
grass2: {
|
||||
hex: "#67B058",
|
||||
rgb: [103, 176, 88] as const,
|
||||
type: "grass" as const,
|
||||
grassTipColor: "#67B058",
|
||||
},
|
||||
grass3: {
|
||||
hex: "#A3CA5B",
|
||||
rgb: [163, 202, 91] as const,
|
||||
type: "grass" as const,
|
||||
grassTipColor: "#A3CA5B",
|
||||
},
|
||||
potager: {
|
||||
hex: "#342420",
|
||||
rgb: [52, 36, 32] as const,
|
||||
type: "tile" as const,
|
||||
tileModel: "/models/potager/potager.gltf",
|
||||
tileSize: 1,
|
||||
},
|
||||
terre: {
|
||||
hex: "#513E2C",
|
||||
rgb: [81, 62, 44] as const,
|
||||
type: "none" as const,
|
||||
},
|
||||
chemin: {
|
||||
hex: "#F5D896",
|
||||
rgb: [245, 216, 150] as const,
|
||||
type: "tile" as const,
|
||||
tileModel: "/models/chemins/model.gltf",
|
||||
tileSize: 1,
|
||||
},
|
||||
eau: {
|
||||
hex: "#91DAF5",
|
||||
rgb: [145, 218, 245] as const,
|
||||
type: "water" as const,
|
||||
},
|
||||
cailloux: {
|
||||
hex: "#B6D3DE",
|
||||
rgb: [182, 211, 222] as const,
|
||||
type: "none" as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
|
||||
export type TerrainType = "grass" | "tile" | "water" | "none";
|
||||
|
||||
export const GRASS_BASE_COLOR = "#1a3a1a";
|
||||
|
||||
export const COLOR_TOLERANCE = 15;
|
||||
|
||||
export function colorMatchesTerrain(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
targetRgb: readonly [number, number, number],
|
||||
tolerance: number = COLOR_TOLERANCE,
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(r - targetRgb[0]) <= tolerance &&
|
||||
Math.abs(g - targetRgb[1]) <= tolerance &&
|
||||
Math.abs(b - targetRgb[2]) <= tolerance
|
||||
);
|
||||
}
|
||||
|
||||
export function getTerrainTypeFromColor(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): TerrainColorKey | null {
|
||||
for (const [key, config] of Object.entries(TERRAIN_COLORS)) {
|
||||
if (colorMatchesTerrain(r, g, b, config.rgb)) {
|
||||
return key as TerrainColorKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isGrassZone(r: number, g: number, b: number): boolean {
|
||||
return (
|
||||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass1.rgb) ||
|
||||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass2.rgb) ||
|
||||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass3.rgb)
|
||||
);
|
||||
}
|
||||
|
||||
export function getGrassTipColor(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): string | null {
|
||||
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass1.rgb)) {
|
||||
return TERRAIN_COLORS.grass1.grassTipColor;
|
||||
}
|
||||
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass2.rgb)) {
|
||||
return TERRAIN_COLORS.grass2.grassTipColor;
|
||||
}
|
||||
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass3.rgb)) {
|
||||
return TERRAIN_COLORS.grass3.grassTipColor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const WIND_DEFAULTS = {
|
||||
speed: 0.3,
|
||||
direction: Math.PI * 0.25,
|
||||
strength: 1.0,
|
||||
noiseScale: 0.9,
|
||||
};
|
||||
|
||||
export const WIND_BOUNDS = {
|
||||
speed: { min: 0, max: 2, step: 0.1 },
|
||||
direction: { min: -Math.PI, max: Math.PI, step: 0.1 },
|
||||
strength: { min: 0, max: 3, step: 0.1 },
|
||||
noiseScale: { min: 0.1, max: 5, step: 0.1 },
|
||||
};
|
||||
|
||||
export type WindState = typeof WIND_DEFAULTS;
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import type * as THREE from "three";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { disposeObject3D } from "@/utils/three/dispose";
|
||||
|
||||
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
|
||||
return useMemo(() => object.clone(true) as T, [object]);
|
||||
const clone = useMemo(() => object.clone(true) as T, [object]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeObject3D(clone);
|
||||
};
|
||||
}, [clone]);
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import type { GraphicsState } from "@/data/world/graphicsConfig";
|
||||
|
||||
export function useGraphicsSettings(): GraphicsState {
|
||||
return useWorldSettingsStore((state) => state.graphics);
|
||||
}
|
||||
|
||||
export function useSetGraphicsSettings(): (
|
||||
graphics: Partial<GraphicsState>
|
||||
) => void {
|
||||
return useWorldSettingsStore((state) => state.setGraphics);
|
||||
}
|
||||
|
||||
export function useDynamicGrass(): boolean {
|
||||
return useWorldSettingsStore((state) => state.graphics.dynamicGrass);
|
||||
}
|
||||
|
||||
export function useDynamicTrees(): boolean {
|
||||
return useWorldSettingsStore((state) => state.graphics.dynamicTrees);
|
||||
}
|
||||
|
||||
export function useDynamicClouds(): boolean {
|
||||
return useWorldSettingsStore((state) => state.graphics.dynamicClouds);
|
||||
}
|
||||
|
||||
export function useShadowsEnabled(): boolean {
|
||||
return useWorldSettingsStore((state) => state.graphics.shadowsEnabled);
|
||||
}
|
||||
|
||||
export function useGrassDensity(): number {
|
||||
return useWorldSettingsStore((state) => state.graphics.grassDensity);
|
||||
}
|
||||
|
||||
export function useGraphicsSetters() {
|
||||
const setDynamicGrass = useWorldSettingsStore(
|
||||
(state) => state.setDynamicGrass
|
||||
);
|
||||
const setDynamicTrees = useWorldSettingsStore(
|
||||
(state) => state.setDynamicTrees
|
||||
);
|
||||
const setDynamicClouds = useWorldSettingsStore(
|
||||
(state) => state.setDynamicClouds
|
||||
);
|
||||
const setShadowsEnabled = useWorldSettingsStore(
|
||||
(state) => state.setShadowsEnabled
|
||||
);
|
||||
const setGrassDensity = useWorldSettingsStore(
|
||||
(state) => state.setGrassDensity
|
||||
);
|
||||
|
||||
return {
|
||||
setDynamicGrass,
|
||||
setDynamicTrees,
|
||||
setDynamicClouds,
|
||||
setShadowsEnabled,
|
||||
setGrassDensity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import type { WindState } from "@/data/world/windConfig";
|
||||
|
||||
export function useWind(): WindState {
|
||||
return useWorldSettingsStore((state) => state.wind);
|
||||
}
|
||||
|
||||
export function useSetWind(): (wind: Partial<WindState>) => void {
|
||||
return useWorldSettingsStore((state) => state.setWind);
|
||||
}
|
||||
|
||||
export function useWindSpeed(): number {
|
||||
return useWorldSettingsStore((state) => state.wind.speed);
|
||||
}
|
||||
|
||||
export function useWindDirection(): number {
|
||||
return useWorldSettingsStore((state) => state.wind.direction);
|
||||
}
|
||||
|
||||
export function useWindStrength(): number {
|
||||
return useWorldSettingsStore((state) => state.wind.strength);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { create } from "zustand";
|
||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||
import {
|
||||
GRAPHICS_DEFAULTS,
|
||||
type GraphicsState,
|
||||
} from "@/data/world/graphicsConfig";
|
||||
|
||||
interface WorldSettingsState {
|
||||
wind: WindState;
|
||||
graphics: GraphicsState;
|
||||
}
|
||||
|
||||
interface WorldSettingsActions {
|
||||
setWind: (wind: Partial<WindState>) => void;
|
||||
setWindSpeed: (speed: number) => void;
|
||||
setWindDirection: (direction: number) => void;
|
||||
setWindStrength: (strength: number) => void;
|
||||
setGraphics: (graphics: Partial<GraphicsState>) => void;
|
||||
setDynamicGrass: (enabled: boolean) => void;
|
||||
setDynamicTrees: (enabled: boolean) => void;
|
||||
setDynamicClouds: (enabled: boolean) => void;
|
||||
setShadowsEnabled: (enabled: boolean) => void;
|
||||
setGrassDensity: (density: number) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
|
||||
|
||||
const DEFAULT_STATE: WorldSettingsState = {
|
||||
wind: { ...WIND_DEFAULTS },
|
||||
graphics: { ...GRAPHICS_DEFAULTS },
|
||||
};
|
||||
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
...DEFAULT_STATE,
|
||||
|
||||
setWind: (windUpdate) =>
|
||||
set((state) => ({
|
||||
wind: { ...state.wind, ...windUpdate },
|
||||
})),
|
||||
|
||||
setWindSpeed: (speed) =>
|
||||
set((state) => ({
|
||||
wind: { ...state.wind, speed },
|
||||
})),
|
||||
|
||||
setWindDirection: (direction) =>
|
||||
set((state) => ({
|
||||
wind: { ...state.wind, direction },
|
||||
})),
|
||||
|
||||
setWindStrength: (strength) =>
|
||||
set((state) => ({
|
||||
wind: { ...state.wind, strength },
|
||||
})),
|
||||
|
||||
setGraphics: (graphicsUpdate) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, ...graphicsUpdate },
|
||||
})),
|
||||
|
||||
setDynamicGrass: (dynamicGrass) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, dynamicGrass },
|
||||
})),
|
||||
|
||||
setDynamicTrees: (dynamicTrees) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, dynamicTrees },
|
||||
})),
|
||||
|
||||
setDynamicClouds: (dynamicClouds) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, dynamicClouds },
|
||||
})),
|
||||
|
||||
setShadowsEnabled: (shadowsEnabled) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, shadowsEnabled },
|
||||
})),
|
||||
|
||||
setGrassDensity: (grassDensity) =>
|
||||
set((state) => ({
|
||||
graphics: { ...state.graphics, grassDensity },
|
||||
})),
|
||||
|
||||
resetToDefaults: () => set(DEFAULT_STATE),
|
||||
}));
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export function disposeObject3D(object: THREE.Object3D): void {
|
||||
object.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry?.dispose();
|
||||
|
||||
if (Array.isArray(child.material)) {
|
||||
for (const material of child.material) {
|
||||
disposeMaterial(material);
|
||||
}
|
||||
} else if (child.material) {
|
||||
disposeMaterial(child.material);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material): void {
|
||||
material.dispose();
|
||||
|
||||
for (const key of Object.keys(material)) {
|
||||
const value = (material as Record<string, unknown>)[key];
|
||||
if (value instanceof THREE.Texture) {
|
||||
value.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeInstancedMesh(mesh: THREE.InstancedMesh): void {
|
||||
mesh.geometry?.dispose();
|
||||
|
||||
if (Array.isArray(mesh.material)) {
|
||||
for (const material of mesh.material) {
|
||||
disposeMaterial(material);
|
||||
}
|
||||
} else if (mesh.material) {
|
||||
disposeMaterial(mesh.material);
|
||||
}
|
||||
|
||||
mesh.dispose();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import type { VegetationInstance } from "@/world/vegetation/useVegetationData";
|
||||
import { disposeInstancedMesh } from "@/utils/three/dispose";
|
||||
|
||||
interface InstancedVegetationProps {
|
||||
modelPath: string;
|
||||
instances: VegetationInstance[];
|
||||
castShadow: boolean;
|
||||
receiveShadow: boolean;
|
||||
}
|
||||
|
||||
interface MeshData {
|
||||
geometry: THREE.BufferGeometry;
|
||||
material: THREE.Material | THREE.Material[];
|
||||
}
|
||||
|
||||
function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||
const meshes: MeshData[] = [];
|
||||
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
meshes.push({
|
||||
geometry: child.geometry.clone(),
|
||||
material: Array.isArray(child.material)
|
||||
? child.material.map((m) => m.clone())
|
||||
: child.material.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return meshes;
|
||||
}
|
||||
|
||||
function createInstanceMatrices(
|
||||
instances: VegetationInstance[],
|
||||
): THREE.Matrix4[] {
|
||||
const matrices: THREE.Matrix4[] = [];
|
||||
const position = new THREE.Vector3();
|
||||
const rotation = new THREE.Euler();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
|
||||
for (const instance of instances) {
|
||||
const matrix = new THREE.Matrix4();
|
||||
|
||||
position.set(...instance.position);
|
||||
rotation.set(...instance.rotation);
|
||||
quaternion.setFromEuler(rotation);
|
||||
scale.set(...instance.scale);
|
||||
|
||||
matrix.compose(position, quaternion, scale);
|
||||
matrices.push(matrix);
|
||||
}
|
||||
|
||||
return matrices;
|
||||
}
|
||||
|
||||
export function InstancedVegetation({
|
||||
modelPath,
|
||||
instances,
|
||||
castShadow,
|
||||
receiveShadow,
|
||||
}: InstancedVegetationProps): React.JSX.Element | null {
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
const meshDataList = useMemo(() => extractMeshes(scene), [scene]);
|
||||
const matrices = useMemo(
|
||||
() => createInstanceMatrices(instances),
|
||||
[instances],
|
||||
);
|
||||
|
||||
const instancedMeshes = useMemo(() => {
|
||||
return meshDataList.map((meshData, index) => {
|
||||
const instancedMesh = new THREE.InstancedMesh(
|
||||
meshData.geometry,
|
||||
meshData.material,
|
||||
instances.length,
|
||||
);
|
||||
|
||||
for (let i = 0; i < matrices.length; i++) {
|
||||
instancedMesh.setMatrixAt(i, matrices[i]);
|
||||
}
|
||||
|
||||
instancedMesh.instanceMatrix.needsUpdate = true;
|
||||
instancedMesh.castShadow = castShadow;
|
||||
instancedMesh.receiveShadow = receiveShadow;
|
||||
instancedMesh.name = `instanced-mesh-${index}`;
|
||||
instancedMesh.frustumCulled = true;
|
||||
instancedMesh.computeBoundingSphere();
|
||||
|
||||
return instancedMesh;
|
||||
});
|
||||
}, [meshDataList, matrices, instances.length, castShadow, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
for (const mesh of instancedMeshes) {
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const mesh of instancedMeshes) {
|
||||
group.remove(mesh);
|
||||
disposeInstancedMesh(mesh);
|
||||
}
|
||||
};
|
||||
}, [instancedMeshes]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const meshData of meshDataList) {
|
||||
meshData.geometry.dispose();
|
||||
if (Array.isArray(meshData.material)) {
|
||||
for (const mat of meshData.material) {
|
||||
mat.dispose();
|
||||
}
|
||||
} else {
|
||||
meshData.material.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [meshDataList]);
|
||||
|
||||
if (instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <group ref={groupRef} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Suspense } from "react";
|
||||
import { InstancedVegetation } from "@/world/vegetation/InstancedVegetation";
|
||||
import { useVegetationData } from "@/world/vegetation/useVegetationData";
|
||||
import {
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/world/vegetation/vegetationConfig";
|
||||
|
||||
export function VegetationSystem(): React.JSX.Element | null {
|
||||
const { data, isLoading } = useVegetationData();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabledTypes = Object.entries(VEGETATION_TYPES).filter(
|
||||
([, config]) => config.enabled,
|
||||
);
|
||||
|
||||
return (
|
||||
<group name="vegetation-system">
|
||||
{enabledTypes.map(([type, config]) => {
|
||||
const instances = data.get(type as VegetationType);
|
||||
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense key={type} fallback={null}>
|
||||
<InstancedVegetation
|
||||
modelPath={config.modelPath}
|
||||
instances={instances}
|
||||
castShadow={config.castShadow}
|
||||
receiveShadow={config.receiveShadow}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getMapNodes, loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
import {
|
||||
VEGETATION_MAX_INSTANCES,
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/world/vegetation/vegetationConfig";
|
||||
|
||||
export interface VegetationInstance {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
|
||||
export type VegetationData = Map<VegetationType, VegetationInstance[]>;
|
||||
|
||||
function mapNodeToInstance(node: MapNode): VegetationInstance {
|
||||
return {
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function extractVegetationData(mapNodes: MapNode[]): VegetationData {
|
||||
const data: VegetationData = new Map();
|
||||
|
||||
for (const [type, config] of Object.entries(VEGETATION_TYPES)) {
|
||||
if (!config.enabled) continue;
|
||||
|
||||
const instances = mapNodes
|
||||
.filter((node) => node.name === config.mapName)
|
||||
.slice(0, VEGETATION_MAX_INSTANCES)
|
||||
.map(mapNodeToInstance);
|
||||
|
||||
if (instances.length > 0) {
|
||||
data.set(type as VegetationType, instances);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function useVegetationData(): {
|
||||
data: VegetationData | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const [data, setData] = useState<VegetationData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const cachedNodes = getMapNodes();
|
||||
|
||||
if (cachedNodes) {
|
||||
if (!cancelled) {
|
||||
setData(extractVegetationData(cachedNodes));
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await loadMapSceneData();
|
||||
const nodes = getMapNodes();
|
||||
|
||||
if (!cancelled && nodes) {
|
||||
setData(extractVegetationData(nodes));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export const VEGETATION_LOD = {
|
||||
windAnimationRadius: 70,
|
||||
windFadeStart: 50,
|
||||
windFadeEnd: 70,
|
||||
};
|
||||
|
||||
export const VEGETATION_MAX_INSTANCES = 500;
|
||||
|
||||
export const VEGETATION_TYPES = {
|
||||
buissons: {
|
||||
mapName: "buissons",
|
||||
modelPath: "/models/buisson/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: false,
|
||||
windEnabled: false,
|
||||
windIntensity: 1.2,
|
||||
},
|
||||
sapin: {
|
||||
mapName: "sapin",
|
||||
modelPath: "/models/sapin/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
windEnabled: false,
|
||||
windIntensity: 0.6,
|
||||
},
|
||||
arbre: {
|
||||
mapName: "arbre",
|
||||
modelPath: "/models/arbre/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: true,
|
||||
windEnabled: false,
|
||||
windIntensity: 0.8,
|
||||
},
|
||||
champdeble: {
|
||||
mapName: "champdeble",
|
||||
modelPath: "/models/champdeble/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: false,
|
||||
windEnabled: false,
|
||||
windIntensity: 1.0,
|
||||
},
|
||||
champdesoja: {
|
||||
mapName: "champdesoja",
|
||||
modelPath: "/models/champdesoja/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: false,
|
||||
windEnabled: false,
|
||||
windIntensity: 1.0,
|
||||
},
|
||||
champsdetournesol: {
|
||||
mapName: "champsdetournesol",
|
||||
modelPath: "/models/champsdetournesol/model.gltf",
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
enabled: false,
|
||||
windEnabled: false,
|
||||
windIntensity: 0.9,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type VegetationType = keyof typeof VEGETATION_TYPES;
|
||||
export type VegetationConfig = (typeof VEGETATION_TYPES)[VegetationType];
|
||||
Reference in New Issue
Block a user