refactor: nettoie l'architecture monde et les docs

This commit is contained in:
tom-boullay
2026-05-28 15:47:16 +02:00
parent 1a91b1d7ae
commit ba50224e6e
45 changed files with 89 additions and 726 deletions
+13 -49
View File
@@ -15,12 +15,9 @@ export class SomeManager {
return SomeManager._instance;
}
private constructor() {
// init logic
}
private constructor() {}
destroy(): void {
// cleanup logic
SomeManager._instance = null;
}
}
@@ -29,42 +26,11 @@ export class SomeManager {
## Managers in this project
| Manager | File | Role |
| -------------------- | ------------------------------------ | ----------------------------------------------------------------------------- |
| -------------------- | ------------------------------------ | -------------------------------------------------------------- |
| `AudioManager` | `src/managers/AudioManager.ts` | Music and SFX playback. |
| `InteractionManager` | `src/managers/InteractionManager.ts` | Focus, nearby, trigger, grab, and hand-grab interaction state. |
| `GameManager` | target-state only | Future single source of truth for phase, zone, mission, input lock, dialogue. |
| `CinematicManager` | target-state only | Future GSAP timeline orchestrator. |
| `ZoneManager` | target-state only | Future zone entry/exit detection and LOD triggers. |
## Target-State GameManager
`GameManager` does not exist in the current implementation. The following pattern is target-state guidance only and should not be applied until the manager exists in code.
```ts
export class GameManager {
cinematic!: CinematicManager;
audio!: AudioManager;
zone!: ZoneManager;
private constructor() {
this.cinematic = CinematicManager.getInstance();
this.audio = AudioManager.getInstance();
this.zone = ZoneManager.getInstance();
}
}
```
When a `GameManager` exists, components and hooks should access other managers through it:
```ts
// Correct
GameManager.getInstance().cinematic.play("intro");
// Wrong — never import sub-managers directly in components
CinematicManager.getInstance().play("intro");
```
## Target-State Subscribe Pattern
## Subscribe Pattern
```ts
private listeners = new Set<() => void>()
@@ -79,28 +45,26 @@ private emit(): void {
}
```
In that target-state manager, every `set*()` method calls `this.emit()` to notify subscribers.
Managers that expose state to React call `this.emit()` from every `set*()` method that changes subscribed state.
## Target-State React Bridge Hook
## React Bridge Hook
```ts
// hooks/useGameState.ts
export function useGameState() {
const game = GameManager.getInstance();
const [state, setState] = useState(game.getState());
// hooks/interaction/useInteraction.ts
const manager = InteractionManager.getInstance();
useEffect(() => {
return game.subscribe(() => setState({ ...game.getState() }));
}, [game]);
return state;
export function useInteraction(): InteractionSnapshot {
return useSyncExternalStore(
manager.subscribe.bind(manager),
manager.getState.bind(manager),
);
}
```
## Rules
- Do not add a `GameManager` unless the feature requires a real shared gameplay state owner.
- Current managers may be imported directly until the target-state orchestrator exists.
- Current managers may be imported directly.
- Keep singleton managers limited to side-effect services or shared interaction state.
- Always call `destroy()` on cleanup when a manager owns external resources.
- Never create manager instances with `new` — always use `.getInstance()`.
-1
View File
@@ -84,7 +84,6 @@ jobs:
SIZE=$(du -k dist/assets | cut -f1)
echo "Bundle size: ${SIZE}KB"
# Threshold: 5000KB (configurable)
THRESHOLD=5000
if [ "$SIZE" -gt "$THRESHOLD" ]; then
+2 -3
View File
@@ -18,7 +18,7 @@ The current prototype puts the player in a repair-oriented world where they prog
- Category-based audio manager for music, SFX, and dialogue
- Dialogue manifest, SRT subtitles, subtitle overlay, and dialogue queueing
- Cinematic manifest with GSAP camera keyframes and optional dialogue cues
- In-game settings menu for volumes, subtitles, subtitle language, and the currently staged repair-runtime toggle
- In-game settings menu for volumes, subtitles, and subtitle language
- Debug mode with `?debug`, lil-gui controls, game-state panel, hand-tracking panel, debug camera, physics playground, and R3F perf
- `/editor` route for map transforms, SRT editing, dialogue manifest editing, cinematic manifest editing, preview, validation, export, and dev-server saves
- `/docs` route that renders the repository documentation inside the app
@@ -154,8 +154,7 @@ WS ws://localhost:8000/ws
## Current Caveats
- This is still a prototype, not a complete game runtime.
- The repair-runtime toggle is stored in settings and displayed in the UI, but the repair game currently still runs locally in React/Three.
- `useRepairMovementLocked()` currently returns `false`, so the movement-lock rule and indicator are present but disabled on `develop`.
- `useRepairMovementLocked()` locks player movement during focused repair steps and drives the repair movement indicator.
- Production editor persistence does not exist. Save endpoints in `vite.config.ts` are local Vite dev-server helpers.
- The player uses octree collision while gameplay objects use Rapier. Keep that boundary deliberate unless the whole player controller is migrated.
+1 -1
View File
@@ -46,7 +46,7 @@ It supports:
The debug physics scene currently uses it to preview:
```txt
public/models/electricienne_animated/model.gltf
public/models/electricienne-animated/model.gltf
```
with the `Dance` animation.
+1 -2
View File
@@ -297,8 +297,7 @@ public/models/{name}/model.gltf
- The repository is still a prototype.
- There is no central production `GameManager`.
- The repair game is implemented, but broader mission orchestration is still light.
- `useRepairMovementLocked()` currently returns `false`, so repair movement lock is disabled even though the rule and UI component exist.
- The repair-runtime setting is stored in settings but not consumed by the repair-game implementation.
- `useRepairMovementLocked()` locks player movement during focused repair steps.
- Player collision and Rapier gameplay physics are separate systems.
- Editor persistence is local development tooling only.
- Debug systems are still part of active scene composition and should remain easy to identify.
+1 -1
View File
@@ -184,7 +184,7 @@ Input is ignored while:
- the settings menu is open
- a cinematic is playing
Movement lock is read separately from `useRepairMovementLocked`, but that hook currently returns `false` on this branch.
Movement lock is read separately from `useRepairMovementLocked`, which locks the player during focused repair steps.
## UI Prompt
+2 -2
View File
@@ -29,9 +29,9 @@ They are under `src/managers/stores/` because they are shared runtime state, not
## Store Responsibilities
| Store | Responsibility |
| ------------------ | ----------------------------------------------------------------- |
| ------------------ | ------------------------------------------------------------- |
| `useGameStore` | Durable game progression, mission steps, cinematic input lock |
| `useSettingsStore` | Menu visibility, volumes, subtitle options, repair-runtime toggle |
| `useSettingsStore` | Menu visibility, volumes, and subtitle options |
| `useSubtitleStore` | Currently displayed subtitle cue |
## Managers vs Stores
+4 -5
View File
@@ -37,7 +37,7 @@ This document lists the user-visible and developer-facing features implemented i
- Input lock while the settings menu is open
- Input lock while a cinematic is playing
- Octree collision against dedicated map collision nodes, currently scoped to the `terrain` node
- Repair movement-lock hook and indicator exist, but the hook currently returns `false`, so movement is not locked during repair on the current branch
- Repair movement lock during focused repair steps, with a matching UI indicator
## Physics And Collision
@@ -108,12 +108,11 @@ This document lists the user-visible and developer-facing features implemented i
- Music, SFX, and dialogue volume sliders
- Subtitle visibility toggle
- Subtitle language choice between French and English
- Repair-runtime choice between JavaScript and Python modes stored in settings
- Quit action that clears browser-accessible cookies and returns to `/`
- Crosshair overlay
- Interaction prompt
- Subtitle overlay
- Repair movement-lock indicator component, currently inactive because the lock hook returns `false`
- Repair movement-lock indicator
- Debug overlay layout
- Scene loading overlay
@@ -192,7 +191,7 @@ This document lists the user-visible and developer-facing features implemented i
- Debug game-state panel
- Debug hand-tracking panel
- Physics test scene with floor, grabbable object, trigger object, repair zones, and animated model preview
- Animated `electricienne_animated` model preview restored in the debug physics scene
- Animated `electricienne-animated` model preview restored in the debug physics scene
## Map And Content Editor
@@ -230,7 +229,7 @@ This document lists the user-visible and developer-facing features implemented i
- Technical docs for architecture, scene runtime, repair game, interaction, editor, audio, hand tracking, Zustand, Three debugging, animation, and target architecture
- User docs for implemented features, main feature, editor usage, and code-review preparation
## Not Implemented Or Incomplete
## Known Gaps
- Complete production mission manager/orchestrator
- Full mission HUD or minimap
+2 -2
View File
@@ -56,10 +56,10 @@ The mission config now carries the mission-specific variations. `ebike` repairs
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML indicator intended for repair movement lock.
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML repair movement-lock indicator.
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows.
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator, but currently returns `false`.
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator.
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
+2 -2
View File
@@ -6,14 +6,14 @@ interface DocsDocumentProps {
title: string;
meta: string;
content: string;
frContent: string;
frContent?: string;
}
export function DocsDocument({
title,
meta,
content,
frContent,
frContent = content,
}: DocsDocumentProps): React.JSX.Element {
const { language, toggleLanguage } = useDocsLanguage();
const hasAlternateContent = frContent !== content;
+8 -2
View File
@@ -496,10 +496,16 @@ export function EditorSrtPanel(): React.JSX.Element {
setContent(await response.text());
setStatus(`Charge depuis ${srtPath}`);
})
.catch(() => {
.catch((error: unknown) => {
if (!mounted) return;
setContent(srtTemplate);
setStatus("Erreur de chargement, template local cree");
setStatus(
`Erreur de chargement, template local cree: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
logger.warn("EditorSrt", "Falling back to local SRT template", {
srtPath,
error: error instanceof Error ? error : String(error),
});
});
return () => {
+5 -10
View File
@@ -1,17 +1,12 @@
import { useGLTF } from "@react-three/drei";
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
import type { Vector3Tuple } from "@/types/three/three";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
interface EcoleModelProps {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
castShadow?: boolean;
receiveShadow?: boolean;
onLoaded?: () => void;
}
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
@@ -1,17 +1,12 @@
import { useGLTF } from "@react-three/drei";
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
import type { Vector3Tuple } from "@/types/three/three";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
interface FermeVerticaleModelProps {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
castShadow?: boolean;
receiveShadow?: boolean;
onLoaded?: () => void;
}
type FermeVerticaleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function FermeVerticaleModel(
props: FermeVerticaleModelProps,
+5 -10
View File
@@ -1,17 +1,12 @@
import { useGLTF } from "@react-three/drei";
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
import type { Vector3Tuple } from "@/types/three/three";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
interface GenerateurModelProps {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
castShadow?: boolean;
receiveShadow?: boolean;
onLoaded?: () => void;
}
type GenerateurModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function GenerateurModel(
props: GenerateurModelProps,
+5 -10
View File
@@ -1,17 +1,12 @@
import { useGLTF } from "@react-three/drei";
import { MergedStaticMapModel } from "@/components/three/world/MergedStaticMapModel";
import type { Vector3Tuple } from "@/types/three/three";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const LAFABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
interface LafabrikModelProps {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
castShadow?: boolean;
receiveShadow?: boolean;
onLoaded?: () => void;
}
type LafabrikModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={LAFABRIK_MODEL_PATH} {...props} />;
@@ -6,7 +6,7 @@ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
interface MergedStaticMapModelProps {
export interface MergedStaticMapModelProps {
modelPath: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
+1 -14
View File
@@ -7,8 +7,6 @@ import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps {
modelPath: string;
fallbackColor?: string | undefined;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
scale?: number | undefined;
}
@@ -29,7 +27,6 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000;
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
class SkyModelErrorBoundary extends Component<
SkyModelErrorBoundaryProps,
@@ -55,21 +52,12 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackColor,
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
modelPath,
scale = SKY_MODEL_SCALE,
}: SkyModelProps): React.JSX.Element {
const colorFallback = fallbackColor ? (
const fallback = fallbackColor ? (
<color attach="background" args={[fallbackColor]} />
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
</SkyModelErrorBoundary>
) : (
colorFallback
);
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
@@ -154,4 +142,3 @@ function disposeSkyModelMaterials(model: THREE.Object3D): void {
}
useGLTF.preload(SKYBOX_MODEL_PATH);
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
+2 -2
View File
@@ -1,5 +1,3 @@
import type { AudioCategory } from "@/managers/AudioManager";
export const AUDIO_PATHS = {
intro: "/sounds/effect/fa.mp3",
bienvenue: "/sounds/effect/fa.mp3",
@@ -8,6 +6,8 @@ export const AUDIO_PATHS = {
helped: "/sounds/effect/fa.mp3",
} as const;
export type AudioCategory = "music" | "sfx" | "dialogue";
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
music: 1,
sfx: 1,
-2
View File
@@ -1,6 +1,4 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
-12
View File
@@ -1,12 +0,0 @@
import { TERRAIN_COLORS, TERRAIN_TILE_SIZE } from "@/data/world/terrainConfig";
export const PATH_SURFACE_KEY = "chemin";
export const PATH_DEBUG_PREVIEW_ENABLED = false;
export const PATH_TILE_RENDER_ENABLED = false;
export const PATH_TILE_MODEL_PATH = TERRAIN_COLORS.chemin.modelPath;
export const PATH_TILE_SIZE =
TERRAIN_COLORS.chemin.tileSize ?? TERRAIN_TILE_SIZE;
export const PATH_TILE_SAMPLE_STEP = 2;
export const PATH_TILE_MAX_COUNT = 1500;
export const PATH_TILE_ROTATION = [0, 0, 0] as const;
export const PATH_TILE_SCALE = [1, 1, 1] as const;
-5
View File
@@ -1,5 +0,0 @@
import { useGameStore } from "@/managers/stores/useGameStore";
export function useActivityCity(): boolean {
return useGameStore((state) => state.missionFlow.activityCity);
}
-64
View File
@@ -1,64 +0,0 @@
import { useMemo } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type {
TerrainSurfaceBounds,
TerrainSurfaceData,
} from "@/types/world/terrainSurface";
import { createTerrainSurfaceImageData } from "@/utils/world/terrainSurfaceSampler";
function findTerrainBaseColorTexture(
scene: THREE.Object3D,
): THREE.Texture | null {
let texture: THREE.Texture | null = null;
scene.traverse((child) => {
if (texture || !(child instanceof THREE.Mesh)) return;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
for (const material of materials) {
if (material instanceof THREE.MeshStandardMaterial && material.map) {
texture = material.map;
return;
}
}
});
return texture;
}
function createTerrainSurfaceBounds(
scene: THREE.Object3D,
): TerrainSurfaceBounds {
scene.updateWorldMatrix(true, true);
const box = new THREE.Box3().setFromObject(scene);
return {
minX: box.min.x,
maxX: box.max.x,
minZ: box.min.z,
maxZ: box.max.z,
};
}
export function useTerrainSurfaceData(): TerrainSurfaceData | null {
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
return useMemo(() => {
const texture = findTerrainBaseColorTexture(scene);
if (!texture) return null;
const imageData = createTerrainSurfaceImageData(texture);
if (!imageData) return null;
return {
bounds: createTerrainSurfaceBounds(scene),
imageData,
raycastTarget: scene,
};
}, [scene]);
}
+5 -2
View File
@@ -1,7 +1,10 @@
import { DEFAULT_CATEGORY_VOLUMES } from "@/data/audioConfig";
import {
DEFAULT_CATEGORY_VOLUMES,
type AudioCategory,
} from "@/data/audioConfig";
import { logger } from "@/utils/core/Logger";
export type AudioCategory = "music" | "sfx" | "dialogue";
export type { AudioCategory } from "@/data/audioConfig";
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
interface AudioContextWindow extends Window {
-1
View File
@@ -5,7 +5,6 @@ export function DocsAnimationPage(): React.JSX.Element {
return (
<DocsDocument
content={animation}
frContent={animation}
meta="15"
title="Animation & 3D Model System"
/>
-1
View File
@@ -5,7 +5,6 @@ export function DocsArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={architecture}
frContent={architecture}
meta="02"
title="Current Architecture"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsAudioPage(): React.JSX.Element {
return (
<DocsDocument
content={audio}
frContent={audio}
meta="08"
title="Audio Technical Notes"
/>
<DocsDocument content={audio} meta="08" title="Audio Technical Notes" />
);
}
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsCodeReviewPage(): React.JSX.Element {
return (
<DocsDocument
content={codeReview}
frContent={codeReview}
meta="16"
title="Code Review Prep"
/>
<DocsDocument content={codeReview} meta="16" title="Code Review Prep" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import features from "../../../../docs/user/features.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsFeaturesPage(): React.JSX.Element {
return (
<DocsDocument
content={features}
frContent={features}
meta="12"
title="Features"
/>
);
return <DocsDocument content={features} meta="12" title="Features" />;
}
-1
View File
@@ -5,7 +5,6 @@ export function DocsHandTrackingPage(): React.JSX.Element {
return (
<DocsDocument
content={handTracking}
frContent={handTracking}
meta="09"
title="Hand Tracking Technical Notes"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsInteractionPage(): React.JSX.Element {
return (
<DocsDocument
content={interaction}
frContent={interaction}
meta="05"
title="Interaction System"
/>
<DocsDocument content={interaction} meta="05" title="Interaction System" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import mainFeature from "../../../../docs/user/main-feature.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsMainFeaturePage(): React.JSX.Element {
return (
<DocsDocument
content={mainFeature}
frContent={mainFeature}
meta="13"
title="Main Feature"
/>
);
return <DocsDocument content={mainFeature} meta="13" title="Main Feature" />;
}
+1 -8
View File
@@ -2,12 +2,5 @@ import readme from "../../../README.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsReadmePage(): React.JSX.Element {
return (
<DocsDocument
content={readme}
frContent={readme}
meta="01"
title="README"
/>
);
return <DocsDocument content={readme} meta="01" title="README" />;
}
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsSceneRuntimePage(): React.JSX.Element {
return (
<DocsDocument
content={sceneRuntime}
frContent={sceneRuntime}
meta="03"
title="Scene Runtime"
/>
<DocsDocument content={sceneRuntime} meta="03" title="Scene Runtime" />
);
}
@@ -5,7 +5,6 @@ export function DocsTargetArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={targetArchitecture}
frContent={targetArchitecture}
meta="06"
title="Target Architecture"
/>
-1
View File
@@ -5,7 +5,6 @@ export function DocsTechnicalEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={technicalEditor}
frContent={technicalEditor}
meta="07"
title="Editor Technical Notes"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsThreeDebuggingPage(): React.JSX.Element {
return (
<DocsDocument
content={threeDebugging}
frContent={threeDebugging}
meta="11"
title="Three Debugging"
/>
<DocsDocument content={threeDebugging} meta="11" title="Three Debugging" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import zustand from "../../../../docs/technical/zustand.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsZustandPage(): React.JSX.Element {
return (
<DocsDocument
content={zustand}
frContent={zustand}
meta="10"
title="Zustand Stores"
/>
);
return <DocsDocument content={zustand} meta="10" title="Zustand Stores" />;
}
-26
View File
@@ -1,5 +1,3 @@
import type * as THREE from "three";
export type TerrainSurfaceKind =
| "grass"
| "path"
@@ -10,18 +8,6 @@ export type TerrainSurfaceKind =
export type TerrainSurfaceRgb = readonly [number, number, number];
export interface TerrainSurfaceUv {
u: number;
v: number;
}
export interface TerrainSurfaceProjectionConfig {
flipX?: boolean;
flipZ?: boolean;
offsetX?: number;
offsetZ?: number;
}
export interface TerrainSurfaceBounds {
minX: number;
maxX: number;
@@ -37,15 +23,3 @@ export interface TerrainSurfaceColorConfig {
modelPath?: string;
tileSize?: number;
}
export interface TerrainSurfaceSample {
rgb: TerrainSurfaceRgb;
key: string | null;
config: TerrainSurfaceColorConfig | null;
}
export interface TerrainSurfaceData {
bounds: TerrainSurfaceBounds;
imageData: ImageData;
raycastTarget: THREE.Object3D;
}
-66
View File
@@ -1,66 +0,0 @@
import * as THREE from "three";
type TextureMaterialKey = Extract<
| keyof THREE.MeshBasicMaterial
| keyof THREE.MeshStandardMaterial
| keyof THREE.MeshPhysicalMaterial
| keyof THREE.MeshToonMaterial,
string
>;
type MaterialWithTextureSlots = THREE.Material &
Partial<Record<TextureMaterialKey, THREE.Texture | null>>;
const MATERIAL_TEXTURE_KEYS = [
"alphaMap",
"aoMap",
"bumpMap",
"clearcoatMap",
"clearcoatNormalMap",
"clearcoatRoughnessMap",
"displacementMap",
"emissiveMap",
"envMap",
"gradientMap",
"lightMap",
"map",
"metalnessMap",
"normalMap",
"roughnessMap",
"sheenColorMap",
"sheenRoughnessMap",
"specularColorMap",
"specularIntensityMap",
"specularMap",
"thicknessMap",
"transmissionMap",
] as const satisfies readonly TextureMaterialKey[];
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();
const materialWithTextures = material as MaterialWithTextureSlots;
for (const key of MATERIAL_TEXTURE_KEYS) {
const value = materialWithTextures[key];
if (value instanceof THREE.Texture) {
value.dispose();
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import {
TERRAIN_COLORS,
TERRAIN_SURFACE_COLOR_TOLERANCE,
type TerrainColorKey,
} from "@/data/world/terrainConfig";
import type { TerrainSurfaceRgb } from "@/types/world/terrainSurface";
export function colorMatchesTerrainSurface(
r: number,
g: number,
b: number,
targetRgb: TerrainSurfaceRgb,
tolerance: number = TERRAIN_SURFACE_COLOR_TOLERANCE,
): boolean {
return (
Math.abs(r - targetRgb[0]) <= tolerance &&
Math.abs(g - targetRgb[1]) <= tolerance &&
Math.abs(b - targetRgb[2]) <= tolerance
);
}
export function getTerrainColorKeyFromRgb(
r: number,
g: number,
b: number,
): TerrainColorKey | null {
for (const [key, config] of Object.entries(TERRAIN_COLORS)) {
if (colorMatchesTerrainSurface(r, g, b, config.rgb)) {
return key as TerrainColorKey;
}
}
return null;
}
export function isGrassTerrainColor(r: number, g: number, b: number): boolean {
const key = getTerrainColorKeyFromRgb(r, g, b);
return key !== null && TERRAIN_COLORS[key].kind === "grass";
}
export function getGrassTipColorFromRgb(
r: number,
g: number,
b: number,
): string | null {
const key = getTerrainColorKeyFromRgb(r, g, b);
if (key === null) return null;
const terrainColor = TERRAIN_COLORS[key];
return "grassTipColor" in terrainColor ? terrainColor.grassTipColor : null;
}
-125
View File
@@ -1,125 +0,0 @@
import * as THREE from "three";
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
import type {
TerrainSurfaceBounds,
TerrainSurfaceProjectionConfig,
TerrainSurfaceRgb,
TerrainSurfaceSample,
TerrainSurfaceUv,
} from "@/types/world/terrainSurface";
import { getTerrainColorKeyFromRgb } from "@/utils/world/terrainSurfaceColor";
type TerrainSurfaceImageSource =
| HTMLImageElement
| HTMLCanvasElement
| ImageBitmap;
const imageDataCache = new WeakMap<TerrainSurfaceImageSource, ImageData>();
function clamp01(value: number): number {
return Math.min(Math.max(value, 0), 1);
}
function wrap01(value: number): number {
return ((value % 1) + 1) % 1;
}
function isTerrainSurfaceImageSource(
value: unknown,
): value is TerrainSurfaceImageSource {
return (
value instanceof HTMLImageElement ||
value instanceof HTMLCanvasElement ||
(typeof ImageBitmap !== "undefined" && value instanceof ImageBitmap)
);
}
export function createTerrainSurfaceImageData(
texture: THREE.Texture,
): ImageData | null {
if (typeof document === "undefined") return null;
const image = texture.image as unknown;
if (!isTerrainSurfaceImageSource(image)) return null;
const cachedImageData = imageDataCache.get(image);
if (cachedImageData) return cachedImageData;
const width = image.width;
const height = image.height;
if (width <= 0 || height <= 0) return null;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return null;
canvas.width = width;
canvas.height = height;
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
imageDataCache.set(image, imageData);
return imageData;
}
export function sampleTerrainSurfaceAtUv(
imageData: ImageData,
uv: TerrainSurfaceUv,
): TerrainSurfaceSample {
const x = Math.round(clamp01(uv.u) * (imageData.width - 1));
const y = Math.round((1 - clamp01(uv.v)) * (imageData.height - 1));
const index = (y * imageData.width + x) * 4;
const rgb: TerrainSurfaceRgb = [
imageData.data[index] ?? 0,
imageData.data[index + 1] ?? 0,
imageData.data[index + 2] ?? 0,
];
const key = getTerrainColorKeyFromRgb(rgb[0], rgb[1], rgb[2]);
return {
rgb,
key,
config: key === null ? null : TERRAIN_COLORS[key],
};
}
export function terrainSurfaceUvFromXZ(
x: number,
z: number,
bounds: TerrainSurfaceBounds,
projection?: TerrainSurfaceProjectionConfig,
): TerrainSurfaceUv {
const width = bounds.maxX - bounds.minX;
const depth = bounds.maxZ - bounds.minZ;
let u = width === 0 ? 0 : x / width + 0.5;
let v = depth === 0 ? 0 : z / depth + 0.5;
if (projection?.flipX) {
u = 1 - u;
}
if (projection?.flipZ) {
v = 1 - v;
}
u = wrap01(u + (projection?.offsetX ?? 0));
v = wrap01(v + (projection?.offsetZ ?? 0));
return {
u,
v,
};
}
export function sampleTerrainSurfaceAtXZ(
imageData: ImageData,
x: number,
z: number,
bounds: TerrainSurfaceBounds,
projection?: TerrainSurfaceProjectionConfig,
): TerrainSurfaceSample {
return sampleTerrainSurfaceAtUv(
imageData,
terrainSurfaceUvFromXZ(x, z, bounds, projection),
);
}
-4
View File
@@ -1,7 +1,5 @@
import {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
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,
@@ -37,8 +35,6 @@ export function Environment(): React.JSX.Element {
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
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}
/>
-87
View File
@@ -1,87 +0,0 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
import {
PATH_DEBUG_PREVIEW_ENABLED,
PATH_TILE_RENDER_ENABLED,
PATH_TILE_MODEL_PATH,
} from "@/data/world/pathConfig";
import { usePathTileData } from "@/world/paths/usePathTileData";
import type { MapAssetInstance } from "@/hooks/world/useMapInstancingData";
export function PathSystem(): React.JSX.Element | null {
if (!PATH_DEBUG_PREVIEW_ENABLED && !PATH_TILE_RENDER_ENABLED) {
return null;
}
return <PathTiles />;
}
function PathTiles(): React.JSX.Element | null {
const pathTiles = usePathTileData();
if (pathTiles.length === 0) {
return null;
}
if (PATH_DEBUG_PREVIEW_ENABLED) {
return <PathDebugPreview instances={pathTiles} />;
}
if (!PATH_TILE_RENDER_ENABLED) {
return null;
}
return (
<InstancedMapAsset
castShadow={false}
instances={pathTiles}
modelPath={PATH_TILE_MODEL_PATH}
receiveShadow
/>
);
}
function PathDebugPreview({
instances,
}: {
instances: MapAssetInstance[];
}): React.JSX.Element {
const instancedMeshRef = useRef<THREE.InstancedMesh>(null);
useEffect(() => {
const instancedMesh = instancedMeshRef.current;
if (!instancedMesh) return;
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3(1, 1, 1);
for (let i = 0; i < instances.length; i++) {
const instance = instances[i];
if (!instance) continue;
position.set(
instance.position[0],
instance.position[1] + 0.08,
instance.position[2],
);
matrix.compose(position, quaternion, scale);
instancedMesh.setMatrixAt(i, matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.computeBoundingSphere();
}, [instances]);
return (
<instancedMesh
ref={instancedMeshRef}
args={[undefined, undefined, instances.length]}
>
<boxGeometry args={[0.35, 0.08, 0.35]} />
<meshBasicMaterial color="#ff00ff" />
</instancedMesh>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { useMemo } from "react";
import { TERRAIN_SURFACE_PROJECTION } from "@/data/world/terrainConfig";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
import type { Vector3Tuple } from "@/types/three/three";
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
import type { MapAssetInstance } from "@/hooks/world/useMapInstancingData";
import {
PATH_TILE_MAX_COUNT,
PATH_SURFACE_KEY,
PATH_TILE_ROTATION,
PATH_TILE_SAMPLE_STEP,
PATH_TILE_SCALE,
} from "@/data/world/pathConfig";
function createSampleCenters(min: number, max: number, step: number): number[] {
const start = Math.ceil(min / step) * step + step * 0.5;
const centers: number[] = [];
for (let value = start; value <= max; value += step) {
centers.push(value);
}
return centers;
}
export function usePathTileData(): MapAssetInstance[] {
const terrainSurfaceData = useTerrainSurfaceData();
const terrainHeight = useTerrainHeightSampler();
return useMemo(() => {
if (!terrainSurfaceData) return [];
const instances: MapAssetInstance[] = [];
const xCenters = createSampleCenters(
terrainSurfaceData.bounds.minX,
terrainSurfaceData.bounds.maxX,
PATH_TILE_SAMPLE_STEP,
);
const zCenters = createSampleCenters(
terrainSurfaceData.bounds.minZ,
terrainSurfaceData.bounds.maxZ,
PATH_TILE_SAMPLE_STEP,
);
for (const x of xCenters) {
for (const z of zCenters) {
if (instances.length >= PATH_TILE_MAX_COUNT) return instances;
const sample = sampleTerrainSurfaceAtXZ(
terrainSurfaceData.imageData,
x,
z,
terrainSurfaceData.bounds,
TERRAIN_SURFACE_PROJECTION,
);
if (sample.key !== PATH_SURFACE_KEY) continue;
const height = terrainHeight.getHeight(x, z) ?? 0;
instances.push({
position: [x, height, z],
rotation: [...PATH_TILE_ROTATION] as Vector3Tuple,
scale: [...PATH_TILE_SCALE] as Vector3Tuple,
});
}
}
return instances;
}, [terrainHeight, terrainSurfaceData]);
}
+9 -10
View File
@@ -107,7 +107,7 @@ const saveSrtPlugin = (): Plugin => ({
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
const data: unknown = JSON.parse(Buffer.concat(chunks).toString());
if (!isSrtPayload(data)) {
sendJson(res, 400, { error: "Invalid SRT payload" });
return;
@@ -189,7 +189,7 @@ const saveDialogueManifestPlugin = (): Plugin => ({
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
const data: unknown = JSON.parse(Buffer.concat(chunks).toString());
parseDialogueManifestData(data);
const manifestPath = path.resolve(
@@ -235,7 +235,7 @@ const saveCinematicManifestPlugin = (): Plugin => ({
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
const data: unknown = JSON.parse(Buffer.concat(chunks).toString());
const manifest = parseCinematicManifestData(data);
const dialogueManifest = await loadDialogueManifestData();
validateCinematicDialogueCues(manifest, dialogueManifest);
@@ -304,15 +304,14 @@ interface CinematicKeyframeData {
}
function isSrtPayload(data: unknown): data is SrtPayload {
if (!data || typeof data !== "object") return false;
if (!isRecord(data)) return false;
const payload = data as Partial<SrtPayload>;
return (
typeof payload.voice === "string" &&
SRT_VOICES.has(payload.voice) &&
typeof payload.language === "string" &&
SRT_LANGUAGES.has(payload.language) &&
typeof payload.content === "string"
typeof data.voice === "string" &&
SRT_VOICES.has(data.voice) &&
typeof data.language === "string" &&
SRT_LANGUAGES.has(data.language) &&
typeof data.content === "string"
);
}