diff --git a/public/assets/gps/map_background.png b/public/assets/gps/map_background.png new file mode 100644 index 0000000..680bb4e --- /dev/null +++ b/public/assets/gps/map_background.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e67d0ab997e0470c66d023cee3dae28be952a09a8dc4c96e047ee79cc4ba995 +size 1070216 diff --git a/public/roadNetwork.json b/public/roadNetwork.json new file mode 100644 index 0000000..5e5bc80 --- /dev/null +++ b/public/roadNetwork.json @@ -0,0 +1,1136 @@ +[ + { + "id": 1, + "x": -4.61, + "y": 7.13, + "z": -2.77, + "connections": [2, 7] + }, + { + "id": 2, + "x": -5.61, + "y": 7.08, + "z": -7.84, + "connections": [1, 3] + }, + { + "id": 3, + "x": -2.36, + "y": 6.96, + "z": -13.75, + "connections": [2, 4] + }, + { + "id": 4, + "x": -3.35, + "y": 6.48, + "z": -22.71, + "connections": [3, 5, 69, 157] + }, + { + "id": 5, + "x": -11.24, + "y": 6.45, + "z": -20.45, + "connections": [4, 6] + }, + { + "id": 6, + "x": -19.8, + "y": 6.46, + "z": -12.16, + "connections": [5, 7, 8, 53] + }, + { + "id": 7, + "x": -10.38, + "y": 6.99, + "z": -7.51, + "connections": [6, 1] + }, + { + "id": 8, + "x": -29.38, + "y": 5.69, + "z": -14.1, + "connections": [6, 9, 15] + }, + { + "id": 9, + "x": -34.88, + "y": 5.18, + "z": -14.73, + "connections": [8, 10, 11] + }, + { + "id": 10, + "x": -43.64, + "y": 4.25, + "z": -16.72, + "connections": [9] + }, + { + "id": 11, + "x": -34.62, + "y": 4.86, + "z": -21.94, + "connections": [9, 12] + }, + { + "id": 12, + "x": -39.38, + "y": 3.98, + "z": -29.69, + "connections": [11, 13] + }, + { + "id": 13, + "x": -46.05, + "y": 3.15, + "z": -34.04, + "connections": [12, 14] + }, + { + "id": 14, + "x": -55.16, + "y": 2.32, + "z": -36.22, + "connections": [13] + }, + { + "id": 15, + "x": -35.85, + "y": 5.37, + "z": -3.43, + "connections": [8, 16, 18] + }, + { + "id": 16, + "x": -37.61, + "y": 5.17, + "z": 5.14, + "connections": [15, 17] + }, + { + "id": 17, + "x": -44.96, + "y": 4.35, + "z": 8.67, + "connections": [16] + }, + { + "id": 18, + "x": -43.46, + "y": 4.57, + "z": -4.93, + "connections": [15, 19] + }, + { + "id": 19, + "x": -52.58, + "y": 3.6, + "z": -5.75, + "connections": [18, 20, 23] + }, + { + "id": 20, + "x": -58.7, + "y": 3.01, + "z": -0.58, + "connections": [19, 21] + }, + { + "id": 21, + "x": -64.82, + "y": 2.4, + "z": 5, + "connections": [20, 22] + }, + { + "id": 22, + "x": -70.26, + "y": 1.95, + "z": 4.73, + "connections": [21] + }, + { + "id": 23, + "x": -60.47, + "y": 2.73, + "z": -11.6, + "connections": [19, 24, 25] + }, + { + "id": 24, + "x": -64.69, + "y": 2.24, + "z": -16.49, + "connections": [23] + }, + { + "id": 25, + "x": -66.78, + "y": 2.17, + "z": -10.64, + "connections": [23, 26] + }, + { + "id": 26, + "x": -76.23, + "y": 1.39, + "z": -15.3, + "connections": [25, 27, 29, 40] + }, + { + "id": 27, + "x": -82.06, + "y": 0.97, + "z": -20.09, + "connections": [26, 28] + }, + { + "id": 28, + "x": -89.41, + "y": 0.54, + "z": -26.27, + "connections": [27] + }, + { + "id": 29, + "x": -82.06, + "y": 1.08, + "z": -11.22, + "connections": [26, 30, 32] + }, + { + "id": 30, + "x": -88.59, + "y": 0.74, + "z": -2.59, + "connections": [29, 31] + }, + { + "id": 31, + "x": -95.82, + "y": 0.45, + "z": 1.02, + "connections": [30] + }, + { + "id": 32, + "x": -90.92, + "y": 0.58, + "z": -14.14, + "connections": [29, 33] + }, + { + "id": 33, + "x": -97.45, + "y": 0.31, + "z": -17.87, + "connections": [32, 34, 36] + }, + { + "id": 34, + "x": -102.82, + "y": 0.14, + "z": -24.4, + "connections": [33, 35] + }, + { + "id": 35, + "x": -108.07, + "y": 0, + "z": -31.52, + "connections": [34] + }, + { + "id": 36, + "x": -102.94, + "y": 0.15, + "z": -14.96, + "connections": [33, 37] + }, + { + "id": 37, + "x": -107.37, + "y": 0.11, + "z": -9.01, + "connections": [36, 38] + }, + { + "id": 38, + "x": -110.75, + "y": 0.08, + "z": -2.59, + "connections": [37, 39] + }, + { + "id": 39, + "x": -116.58, + "y": 0, + "z": -0.38, + "connections": [38] + }, + { + "id": 40, + "x": -76.23, + "y": 1.27, + "z": -22.89, + "connections": [26, 41] + }, + { + "id": 41, + "x": -76.81, + "y": 1.09, + "z": -29.53, + "connections": [40, 42, 47] + }, + { + "id": 42, + "x": -73.43, + "y": 1.08, + "z": -37.23, + "connections": [41, 43] + }, + { + "id": 43, + "x": -71.91, + "y": 1.02, + "z": -42.83, + "connections": [42, 44] + }, + { + "id": 44, + "x": -72.61, + "y": 0.77, + "z": -49.48, + "connections": [43, 45] + }, + { + "id": 45, + "x": -77.51, + "y": 0.43, + "z": -56.36, + "connections": [44] + }, + { + "id": 46, + "x": -64.5, + "y": 2.03, + "z": -25.33, + "connections": [] + }, + { + "id": 47, + "x": -82.6, + "y": 0.64, + "z": -38.22, + "connections": [41, 48] + }, + { + "id": 48, + "x": -87.73, + "y": 0.32, + "z": -45.38, + "connections": [47, 49] + }, + { + "id": 49, + "x": -88.14, + "y": 0.17, + "z": -54.34, + "connections": [48, 50] + }, + { + "id": 50, + "x": -89.12, + "y": 0.09, + "z": -59.31, + "connections": [49, 51] + }, + { + "id": 51, + "x": -92.7, + "y": 0.01, + "z": -63.96, + "connections": [50] + }, + { + "id": 52, + "x": -70.7, + "y": 1.34, + "z": -34.23, + "connections": [] + }, + { + "id": 53, + "x": -23.13, + "y": 6.44, + "z": -4.02, + "connections": [6, 54] + }, + { + "id": 54, + "x": -23.62, + "y": 6.39, + "z": 5.67, + "connections": [53, 55] + }, + { + "id": 55, + "x": -18.16, + "y": 6.37, + "z": 16.59, + "connections": [54, 56, 59, 156] + }, + { + "id": 56, + "x": -14.25, + "y": 6.77, + "z": 11.21, + "connections": [55, 57] + }, + { + "id": 57, + "x": -13.93, + "y": 6.9, + "z": 6.81, + "connections": [56, 58] + }, + { + "id": 58, + "x": -11, + "y": 7.03, + "z": 3.23, + "connections": [57] + }, + { + "id": 59, + "x": -10.67, + "y": 6.41, + "z": 21.39, + "connections": [55, 60] + }, + { + "id": 60, + "x": -1.71, + "y": 6.38, + "z": 24.33, + "connections": [59, 61] + }, + { + "id": 61, + "x": 8.8, + "y": 6.36, + "z": 23.1, + "connections": [60, 62] + }, + { + "id": 62, + "x": 14.42, + "y": 6.42, + "z": 18.95, + "connections": [61, 63, 64, 108] + }, + { + "id": 63, + "x": 2.44, + "y": 7.13, + "z": 3.31, + "connections": [62] + }, + { + "id": 64, + "x": 20.12, + "y": 6.43, + "z": 12.52, + "connections": [62, 65] + }, + { + "id": 65, + "x": 22.48, + "y": 6.49, + "z": 3.88, + "connections": [64, 66] + }, + { + "id": 66, + "x": 21.34, + "y": 6.52, + "z": -6.79, + "connections": [65, 67] + }, + { + "id": 67, + "x": 18.25, + "y": 6.5, + "z": -13.47, + "connections": [66, 68] + }, + { + "id": 68, + "x": 15.23, + "y": 6.54, + "z": -15.99, + "connections": [67, 69, 70, 71] + }, + { + "id": 69, + "x": 5.78, + "y": 6.52, + "z": -21.53, + "connections": [68, 4] + }, + { + "id": 70, + "x": 11.08, + "y": 6.96, + "z": -8.17, + "connections": [68] + }, + { + "id": 71, + "x": 19.39, + "y": 6.07, + "z": -20.63, + "connections": [68, 72, 91] + }, + { + "id": 72, + "x": 21.18, + "y": 5.69, + "z": -24.87, + "connections": [71, 73, 74] + }, + { + "id": 73, + "x": 22.57, + "y": 4.58, + "z": -37.32, + "connections": [72] + }, + { + "id": 74, + "x": 28.76, + "y": 4.96, + "z": -27.8, + "connections": [72, 75, 76] + }, + { + "id": 75, + "x": 36.01, + "y": 4.45, + "z": -26.82, + "connections": [74] + }, + { + "id": 76, + "x": 39.1, + "y": 3.59, + "z": -35.78, + "connections": [74, 77, 78] + }, + { + "id": 77, + "x": 51.07, + "y": 2.37, + "z": -40.58, + "connections": [76] + }, + { + "id": 78, + "x": 39.26, + "y": 2.89, + "z": -45.14, + "connections": [76, 79, 81] + }, + { + "id": 79, + "x": 37.55, + "y": 2.11, + "z": -57.04, + "connections": [78] + }, + { + "id": 80, + "x": 44.58, + "y": 2.21, + "z": -50.17, + "connections": [] + }, + { + "id": 81, + "x": 47.25, + "y": 1.81, + "z": -54.26, + "connections": [78, 82] + }, + { + "id": 82, + "x": 60.2, + "y": 0.9, + "z": -60.53, + "connections": [81, 83, 84] + }, + { + "id": 83, + "x": 75.6, + "y": 0.3, + "z": -63.79, + "connections": [82] + }, + { + "id": 84, + "x": 71.69, + "y": 0.73, + "z": -52.06, + "connections": [82, 85, 86] + }, + { + "id": 85, + "x": 84.72, + "y": 0.41, + "z": -45.14, + "connections": [84] + }, + { + "id": 86, + "x": 72.83, + "y": 0.94, + "z": -43.18, + "connections": [84, 87] + }, + { + "id": 87, + "x": 82.52, + "y": 0.79, + "z": -29.01, + "connections": [86, 88] + }, + { + "id": 88, + "x": 92.95, + "y": 0.47, + "z": -18.1, + "connections": [87, 89] + }, + { + "id": 89, + "x": 100.77, + "y": 0.21, + "z": -16.55, + "connections": [88] + }, + { + "id": 90, + "x": 80.1, + "y": 0.92, + "z": -28.51, + "connections": [] + }, + { + "id": 91, + "x": 31.21, + "y": 5.49, + "z": -15.42, + "connections": [71, 92, 102, 103] + }, + { + "id": 92, + "x": 48.06, + "y": 3.69, + "z": -19.81, + "connections": [91, 93, 95] + }, + { + "id": 93, + "x": 59.71, + "y": 2.45, + "z": -24.13, + "connections": [92] + }, + { + "id": 94, + "x": 56.18, + "y": 3.03, + "z": -15.85, + "connections": [] + }, + { + "id": 95, + "x": 60.85, + "y": 2.78, + "z": -3.28, + "connections": [92, 96, 97] + }, + { + "id": 96, + "x": 68.43, + "y": 2.1, + "z": -2.47, + "connections": [95] + }, + { + "id": 97, + "x": 68.51, + "y": 2.04, + "z": 9.18, + "connections": [95, 98, 99] + }, + { + "id": 98, + "x": 67.86, + "y": 1.98, + "z": 16.59, + "connections": [97] + }, + { + "id": 99, + "x": 91.56, + "y": 0.56, + "z": 13.82, + "connections": [97, 100] + }, + { + "id": 100, + "x": 97.1, + "y": 0.31, + "z": 18.46, + "connections": [99] + }, + { + "id": 101, + "x": 64.61, + "y": 2.41, + "z": 6.58, + "connections": [] + }, + { + "id": 102, + "x": 43.42, + "y": 4.52, + "z": -8.25, + "connections": [91] + }, + { + "id": 103, + "x": 32.83, + "y": 5.63, + "z": -5.24, + "connections": [91, 104] + }, + { + "id": 104, + "x": 32.75, + "y": 5.62, + "z": 6.74, + "connections": [103, 105] + }, + { + "id": 105, + "x": 32.51, + "y": 5.42, + "z": 14.31, + "connections": [104, 106] + }, + { + "id": 106, + "x": 39.02, + "y": 4.65, + "z": 17.98, + "connections": [105, 107] + }, + { + "id": 107, + "x": 44.48, + "y": 4.26, + "z": 14.31, + "connections": [106] + }, + { + "id": 108, + "x": 19.31, + "y": 5.65, + "z": 26.84, + "connections": [62, 109, 131] + }, + { + "id": 109, + "x": 22.49, + "y": 5.23, + "z": 29.86, + "connections": [108, 110, 127] + }, + { + "id": 110, + "x": 32.91, + "y": 3.51, + "z": 42.4, + "connections": [109, 111, 122] + }, + { + "id": 111, + "x": 39.1, + "y": 2.75, + "z": 47.21, + "connections": [110, 112] + }, + { + "id": 112, + "x": 55.64, + "y": 1.54, + "z": 51.12, + "connections": [111, 113, 115, 117] + }, + { + "id": 113, + "x": 61.66, + "y": 1.26, + "z": 49.98, + "connections": [112, 114] + }, + { + "id": 114, + "x": 70.14, + "y": 0.98, + "z": 46.23, + "connections": [113] + }, + { + "id": 115, + "x": 56.45, + "y": 1.31, + "z": 54.86, + "connections": [112] + }, + { + "id": 116, + "x": 54.87, + "y": 1.31, + "z": 56.41, + "connections": [] + }, + { + "id": 117, + "x": 61.42, + "y": 0.86, + "z": 60.39, + "connections": [112, 118] + }, + { + "id": 118, + "x": 60.85, + "y": 0.54, + "z": 70.01, + "connections": [117, 119] + }, + { + "id": 119, + "x": 56.7, + "y": 0.37, + "z": 78.56, + "connections": [118, 120] + }, + { + "id": 120, + "x": 57.11, + "y": 0.24, + "z": 83.2, + "connections": [119] + }, + { + "id": 121, + "x": 43.87, + "y": 1.27, + "z": 66.11, + "connections": [] + }, + { + "id": 122, + "x": 31.12, + "y": 2.64, + "z": 54.13, + "connections": [110, 123, 124] + }, + { + "id": 123, + "x": 25.5, + "y": 2.37, + "z": 60.15, + "connections": [122] + }, + { + "id": 124, + "x": 32.1, + "y": 1.84, + "z": 64.06, + "connections": [122, 125] + }, + { + "id": 125, + "x": 26.07, + "y": 1.22, + "z": 75.79, + "connections": [124, 126] + }, + { + "id": 126, + "x": 26.07, + "y": 1, + "z": 79.54, + "connections": [125] + }, + { + "id": 127, + "x": 31.39, + "y": 4.75, + "z": 27.95, + "connections": [109, 128] + }, + { + "id": 128, + "x": 41.12, + "y": 4.07, + "z": 25.58, + "connections": [127, 129] + }, + { + "id": 129, + "x": 46.63, + "y": 3.54, + "z": 26.12, + "connections": [128, 130] + }, + { + "id": 130, + "x": 53.28, + "y": 2.65, + "z": 32.44, + "connections": [129] + }, + { + "id": 131, + "x": 19.13, + "y": 4.92, + "z": 35.57, + "connections": [108, 132] + }, + { + "id": 132, + "x": 16.75, + "y": 4.05, + "z": 45.62, + "connections": [131, 133] + }, + { + "id": 133, + "x": 11.18, + "y": 3.33, + "z": 54.32, + "connections": [132, 134, 143] + }, + { + "id": 134, + "x": 7.83, + "y": 2.97, + "z": 58.54, + "connections": [133, 135, 139] + }, + { + "id": 135, + "x": 2.7, + "y": 2.73, + "z": 61.4, + "connections": [134, 136] + }, + { + "id": 136, + "x": -2.22, + "y": 2.81, + "z": 60.59, + "connections": [135, 137] + }, + { + "id": 137, + "x": -2.98, + "y": 3.58, + "z": 52.97, + "connections": [136] + }, + { + "id": 139, + "x": 4.05, + "y": 1.58, + "z": 74.79, + "connections": [134, 140] + }, + { + "id": 140, + "x": -0.33, + "y": 1.21, + "z": 80.47, + "connections": [139, 141] + }, + { + "id": 141, + "x": -3.24, + "y": 1.24, + "z": 79.76, + "connections": [140, 142] + }, + { + "id": 142, + "x": -3.41, + "y": 1.73, + "z": 73.01, + "connections": [141] + }, + { + "id": 143, + "x": 11.78, + "y": 0.74, + "z": 87.87, + "connections": [133, 145, 149] + }, + { + "id": 144, + "x": 15.36, + "y": 0.75, + "z": 87.1, + "connections": [] + }, + { + "id": 145, + "x": 7.89, + "y": 0.44, + "z": 94.98, + "connections": [143, 146] + }, + { + "id": 146, + "x": 4.48, + "y": 0.31, + "z": 98.94, + "connections": [145, 147] + }, + { + "id": 147, + "x": -2.22, + "y": 0.27, + "z": 100.07, + "connections": [146, 148] + }, + { + "id": 148, + "x": -3.03, + "y": 0.47, + "z": 94.56, + "connections": [147] + }, + { + "id": 149, + "x": 11.78, + "y": 0.29, + "z": 98.56, + "connections": [143, 150] + }, + { + "id": 150, + "x": 9.61, + "y": 0.05, + "z": 108.88, + "connections": [149, 151] + }, + { + "id": 151, + "x": 5.08, + "y": 0, + "z": 117.04, + "connections": [150, 152] + }, + { + "id": 152, + "x": -0.49, + "y": 0, + "z": 119.26, + "connections": [151, 153] + }, + { + "id": 153, + "x": -5.3, + "y": 0, + "z": 116.93, + "connections": [152] + }, + { + "id": 154, + "x": 17.24, + "y": 0, + "z": 117.06, + "connections": [] + }, + { + "id": 156, + "x": -45.93, + "y": 2.77, + "z": 40.28, + "connections": [55] + }, + { + "id": 157, + "x": -7.58, + "y": 5.97, + "z": -28.56, + "connections": [4, 158] + }, + { + "id": 158, + "x": -17.19, + "y": 2.57, + "z": -60.82, + "connections": [157, 159, 162] + }, + { + "id": 159, + "x": -2.45, + "y": 2.37, + "z": -65.38, + "connections": [158, 160] + }, + { + "id": 160, + "x": 7.4, + "y": 2.46, + "z": -63.99, + "connections": [159, 161] + }, + { + "id": 161, + "x": 24.59, + "y": 2.71, + "z": -56.66, + "connections": [160] + }, + { + "id": 162, + "x": -31.53, + "y": 2.44, + "z": -56.42, + "connections": [158, 163] + }, + { + "id": 163, + "x": -39.67, + "y": 2.4, + "z": -51.61, + "connections": [162, 164] + }, + { + "id": 164, + "x": -49.61, + "y": 2.28, + "z": -44.28, + "connections": [163] + } +] diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx new file mode 100644 index 0000000..13cfdce --- /dev/null +++ b/src/components/ebike/Ebike.tsx @@ -0,0 +1,293 @@ +import { useEffect, useRef, useState, useMemo } from "react"; +import * as THREE from "three"; +import { useFrame, useThree } from "@react-three/fiber"; +import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; +import { InteractableObject } from "@/components/three/interaction/InteractableObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; +import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; +import { animateCameraTransformTransition } from "@/world/GameCinematics"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig"; +import type { Vector3Tuple } from "@/types/three/three"; + +const EBIKE_MODEL_PATH = "/models/ebike/model.gltf"; + +export interface CameraTransform { + position: Vector3Tuple; + rotation: Vector3Tuple; +} + +export const EBIKE_CAMERA_TRANSFORM: CameraTransform = { + position: [-3.5, 6, 0], + rotation: [-10, -90, 0], +}; + +const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { + position: [0, 1.5, -3], + rotation: [0, 0, 0], +}; + +interface EbikeProps { + position: Vector3Tuple; +} + +export function Ebike({ position }: EbikeProps): React.JSX.Element { + const groupRef = useRef(null); + const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, { + scope: "Ebike", + position: position, + }); + const model = useClonedObject(scene); + const movementMode = useGameStore((state) => state.player.movementMode); + const mainState = useGameStore((state) => state.mainState); + const camera = useThree((state) => state.camera); + + // Map active mainState to target repair zone coordinate + const destPos = useMemo(() => { + switch (mainState) { + case "ebike": + return { x: 8, y: 0, z: -6 }; + case "pylon": + return { x: 64, y: 0, z: -66 }; + case "farm": + return { x: -24, y: 0, z: 42 }; + default: + return undefined; + } + }, [mainState]); + + // Throttled GPS start position to optimize pathfinding A* algorithm execution + const [gpsStartPos, setGpsStartPos] = useState<{ + x: number; + y: number; + z: number; + }>({ + x: position[0], + y: position[1], + z: position[2], + }); + const lastGpsUpdatePos = useRef( + new THREE.Vector3(...position), + ); + + const restingPosition = useRef([ + position[0], + position[1] - PLAYER_EYE_HEIGHT, + position[2], + ]); + const restingRotation = useRef(0); + const forkRef = useRef(null); + + useEffect(() => { + if (model) { + const fork = model.getObjectByName("fourche"); + if (fork) { + forkRef.current = fork; + } + } + }, [model]); + + useEffect(() => { + (window as any).ebikeVisualGroup = groupRef; + (window as any).ebikeParkedPosition = restingPosition.current; + (window as any).ebikeParkedRotation = restingRotation.current; + return () => { + (window as any).ebikeVisualGroup = null; + (window as any).ebikeParkedPosition = null; + (window as any).ebikeParkedRotation = null; + }; + }, []); + + useFrame((_, delta) => { + if (groupRef.current) { + if (movementMode === "ebike") { + restingPosition.current = [ + groupRef.current.position.x, + groupRef.current.position.y, + groupRef.current.position.z, + ]; + restingRotation.current = groupRef.current.rotation.y; + + // Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis + const steerFactor = (window as any).ebikeSteerFactor || 0; + if (forkRef.current) { + // 15 degrees is 0.26 radians + const targetForkRotation = steerFactor * 0.26; + forkRef.current.rotation.z = THREE.MathUtils.lerp( + forkRef.current.rotation.z, + targetForkRotation, + 12 * delta, + ); + } + + // Throttled GPS start position update to prevent performance loss + const currentPos = groupRef.current.position; + if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) { + lastGpsUpdatePos.current.copy(currentPos); + setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z }); + } + } else { + groupRef.current.position.set(...restingPosition.current); + groupRef.current.rotation.set(0, restingRotation.current, 0); + + // Reset fork rotation when parked + if (forkRef.current) { + forkRef.current.rotation.z = 0; + } + } + (window as any).ebikeParkedPosition = restingPosition.current; + (window as any).ebikeParkedRotation = restingRotation.current; + } + }); + + const camPointPos: Vector3Tuple = [ + restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0], + restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1], + restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2], + ]; + const dropPointPos: Vector3Tuple = [ + restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0], + restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1], + restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2], + ]; + + const handleInteract = (): void => { + if (movementMode === "walk") { + const cameraOffset = new THREE.Vector3( + ...EBIKE_CAMERA_TRANSFORM.position, + ); + cameraOffset.applyAxisAngle( + new THREE.Vector3(0, 1, 0), + restingRotation.current, + ); + + const targetCamPos: Vector3Tuple = [ + restingPosition.current[0] + cameraOffset.x, + restingPosition.current[1] + cameraOffset.y, + restingPosition.current[2] + cameraOffset.z, + ]; + + const targetRotation: Vector3Tuple = [ + EBIKE_CAMERA_TRANSFORM.rotation[0], + EBIKE_CAMERA_TRANSFORM.rotation[1] + + THREE.MathUtils.radToDeg(restingRotation.current), + EBIKE_CAMERA_TRANSFORM.rotation[2], + ]; + + animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => { + useGameStore.getState().setPlayerMovementMode("ebike"); + }); + } else { + const currentPos = new THREE.Vector3(); + if (groupRef.current) { + groupRef.current.getWorldPosition(currentPos); + } else { + currentPos.set(...position); + } + + const targetCamPos: Vector3Tuple = [ + currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0], + currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1], + currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2], + ]; + + // Get camera's current rotation in degrees so we keep the exact orientation during dismount + const currentEuler = new THREE.Euler().setFromQuaternion( + camera.quaternion, + "YXZ", + ); + const targetRotation: Vector3Tuple = [ + THREE.MathUtils.radToDeg(currentEuler.x), + THREE.MathUtils.radToDeg(currentEuler.y), + THREE.MathUtils.radToDeg(currentEuler.z), + ]; + + animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => { + useGameStore.getState().setPlayerMovementMode("walk"); + }); + } + }; + + const handleInteractRef = useRef(handleInteract); + handleInteractRef.current = handleInteract; + + const debugRef = useRef({ showCameraPoints: true }); + const debugActions = useRef({ + toggleRide: () => { + handleInteractRef.current(); + }, + }); + + useDebugFolder("Ebike", (folder) => { + folder + .add(debugRef.current, "showCameraPoints") + .name("Show Camera Points") + .onChange((value: boolean) => { + debugRef.current.showCameraPoints = value; + }); + + folder.add(debugActions.current, "toggleRide").name("Monter / Descendre"); + }); + + return ( + <> + + + + + + + + + + {/* Dynamic 3D GPS Dashboard Screen */} + + + + + + {debugRef.current.showCameraPoints && ( + <> + + + + + + + + + + )} + + ); +} diff --git a/src/components/ebike/EbikeGPSMap.tsx b/src/components/ebike/EbikeGPSMap.tsx new file mode 100644 index 0000000..3aa65cd --- /dev/null +++ b/src/components/ebike/EbikeGPSMap.tsx @@ -0,0 +1,497 @@ +import React, { useRef, useEffect, useState, useMemo } from "react"; +import * as THREE from "three"; +import { + findClosestWaypoint, + findWaypointPath, +} from "@/pathfinding/WaypointAStar"; +import type { Waypoint } from "@/pathfinding/types"; +function computeImageSource( + img: HTMLImageElement | HTMLCanvasElement, + baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number }, + bounds: { minX: number; maxX: number; minZ: number; maxZ: number }, +) { + const imgW = img.width; + const imgH = img.height; + + const baseW = baseBounds.maxX - baseBounds.minX; + const baseH = baseBounds.maxZ - baseBounds.minZ; + + if (baseW === 0 || baseH === 0) { + return { sx: 0, sy: 0, sW: imgW, sH: imgH }; + } + + const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW; + const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH; + const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW; + const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH; + + return { sx, sy, sW, sH }; +} + +export interface EbikeGPSMapProps { + /** + * 3D world position of the player/bike (GPS start point) + * If omitted, snaps to [0,0,0] + */ + startPos?: { x: number; y: number; z: number } | undefined; + destPos?: { x: number; y: number; z: number } | undefined; + + /** + * Optional custom URL to the map background texture. + * If not provided, renders a high-tech minimalist neon blueprint map dynamically. + */ + mapImageUrl?: string; + + /** + * Optional explicit bounds for mapping coordinates. + * If omitted, bounds are calculated automatically to perfectly fit the road network! + */ + worldBounds?: { + minX: number; + maxX: number; + minZ: number; + maxZ: number; + }; + + /** + * Width of the 3D plane mesh (default: 1) + */ + width?: number; + + /** + * Height of the 3D plane mesh (default: 1) + */ + height?: number; + + /** + * Optional world position for the GPS screen (defaults to origin) + */ + position?: [number, number, number]; + + /** + * Resolution of the offscreen canvas used for the map texture. + * Higher values yield sharper rendering at the cost of GPU memory. + * Default: 1024 (1024×1024 px) + */ + canvasSize?: number; + + /** + * Zoom level applied to the map view. + * 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc. + * Values < 1 zoom out beyond the calculated world bounds. + * Default: 1 + */ + zoom?: number; +} + +/** + * EbikeGPSMap + * A premium, state-of-the-art 3D GPS navigation screen for the Ebike. + * Loads the road network, runs A* pathfinding, and renders a glowing, animated + * orange path over a sleek high-tech map background. + */ +export const EbikeGPSMap: React.FC = ({ + startPos = { x: 0, y: 0, z: 0 }, + destPos, + mapImageUrl, + worldBounds, + width = 1, + height = 1, + position = [0, 0, 0], + canvasSize = 1024, + zoom = 1, +}) => { + const [waypoints, setWaypoints] = useState([]); + const [mapImage, setMapImage] = useState< + HTMLImageElement | HTMLCanvasElement | null + >(null); + + // Offscreen high-res canvas for crystal clear rendering + const [offscreenCanvas] = useState(() => { + const canvas = document.createElement("canvas"); + canvas.width = canvasSize; + canvas.height = canvasSize; + return canvas; + }); + + // Resize the canvas whenever canvasSize changes + useEffect(() => { + offscreenCanvas.width = canvasSize; + offscreenCanvas.height = canvasSize; + if (textureRef.current) { + textureRef.current.needsUpdate = true; + } + }, [canvasSize, offscreenCanvas]); + + const textureRef = useRef(null); + const animTimeRef = useRef(0); + + // Load waypoints (localStorage with /roadNetwork.json fallback) + useEffect(() => { + const saved = localStorage.getItem("la-fabrik-waypoints"); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length > 0) { + setWaypoints(parsed); + return; + } + } catch (e) { + console.error( + "[GPS Component] Error loading local storage waypoints", + e, + ); + } + } + + // Fallback to static roadNetwork.json + fetch("/roadNetwork.json") + .then((res) => { + if (res.ok) return res.json(); + throw new Error("Not found"); + }) + .then((data) => { + if (Array.isArray(data)) { + setWaypoints(data); + } + }) + .catch((err) => { + console.log("[GPS Component] No default road network found.", err); + }); + }, []); + + // Pre-load background map image (standard HTML5 Image loader) + // Since the user's PNG is already transparent, we don't need fetch or pixel manipulation! + useEffect(() => { + if (!mapImageUrl) { + setMapImage(null); + return; + } + + const img = new Image(); + img.onload = () => { + setMapImage(img); + }; + img.onerror = () => { + console.warn( + `[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`, + ); + setMapImage(null); + }; + img.src = mapImageUrl; + }, [mapImageUrl]); + + // Determine grid boundaries (before zoom) + const baseBounds = useMemo(() => { + if (worldBounds) return worldBounds; + + if (waypoints.length === 0) { + return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 }; + } + + const xs = waypoints.map((w) => w.x); + const zs = waypoints.map((w) => w.z); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minZ = Math.min(...zs); + const maxZ = Math.max(...zs); + + // Padding (15% to ensure full view breathing room) + const padX = (maxX - minX) * 0.15 || 40; + const padZ = (maxZ - minZ) * 0.15 || 40; + + return { + minX: minX - padX, + maxX: maxX + padX, + minZ: minZ - padZ, + maxZ: maxZ + padZ, + }; + }, [waypoints, worldBounds]); + + // Apply zoom: shrink the view window around the player position + const bounds = useMemo(() => { + const clampedZoom = Math.max(0.1, zoom); + if (clampedZoom === 1) return baseBounds; + + const centerX = startPos.x; + const centerZ = startPos.z; + const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom; + const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom; + + return { + minX: centerX - halfW, + maxX: centerX + halfW, + minZ: centerZ - halfH, + maxZ: centerZ + halfH, + }; + }, [baseBounds, zoom, startPos]); + + // Snapped positions + const startPosSnapped = useMemo(() => { + if (waypoints.length === 0) return null; + return findClosestWaypoint(waypoints, startPos); + }, [waypoints, startPos]); + + const destPosSnapped = useMemo(() => { + if (!destPos || waypoints.length === 0) return null; + return findClosestWaypoint(waypoints, destPos); + }, [waypoints, destPos]); + + // Calculated active A* route + const activePath = useMemo(() => { + if (!startPosSnapped || !destPosSnapped || waypoints.length === 0) + return []; + return findWaypointPath(waypoints, startPosSnapped, destPosSnapped); + }, [waypoints, startPosSnapped, destPosSnapped]); + + // Translation helper: 3D world to Canvas pixels + const worldToCanvas = (wx: number, wz: number, canvasSize: number) => { + const { minX, maxX, minZ, maxZ } = bounds; + const px = ((wx - minX) / (maxX - minX)) * canvasSize; + const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize; + return { x: px, y: py }; + }; + + // Draw loop + const draw = () => { + const canvas = offscreenCanvas; + const ctx = canvas.getContext("2d", { + willReadFrequently: true, + alpha: true, + }); + if (!ctx) return; + + const size = canvas.width; + + ctx.clearRect(0, 0, size, size); + + // 1. Draw Map Background (Image or premium blueprint vectors) + if (mapImage) { + const src = computeImageSource(mapImage, baseBounds, bounds); + const sx = Math.max(0, Math.min(mapImage.width, src.sx)); + const sy = Math.max(0, Math.min(mapImage.height, src.sy)); + const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW)); + const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH)); + + ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size); + ctx.globalAlpha = 1.0; + } else { + // Dynamic Sci-fi background grid (Background is transparent!) + + // Sci-fi subgrid + ctx.strokeStyle = "rgba(30, 41, 59, 0.4)"; + ctx.lineWidth = 1; + const step = size / 32; + for (let x = 0; x < size; x += step) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, size); + ctx.stroke(); + } + for (let y = 0; y < size; y += step) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(size, y); + ctx.stroke(); + } + + // Aesthetic concentric radar topo-rings + ctx.strokeStyle = "rgba(71, 85, 105, 0.06)"; + ctx.lineWidth = 2; + for (let r = size / 6; r < size; r += size / 6) { + ctx.beginPath(); + ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI); + ctx.stroke(); + } + + // Faint diagonal technical accents + ctx.strokeStyle = "rgba(56, 189, 248, 0.03)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(size, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(size, 0); + ctx.lineTo(0, size); + ctx.stroke(); + } + + // 2. Draw Active Orange Glowing Path (Neon Highway effect) + if (activePath.length > 1) { + // Pass 1: Wide transparent orange bloom + ctx.beginPath(); + let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size); + ctx.moveTo(pt.x, pt.y); + for (let i = 1; i < activePath.length; i++) { + pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size); + ctx.lineTo(pt.x, pt.y); + } + ctx.strokeStyle = "rgba(249, 115, 22, 0.2)"; // Faint bright orange + ctx.lineWidth = 20; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.shadowBlur = 30; + ctx.shadowColor = "#f97316"; // Neon Orange + ctx.stroke(); + + // Pass 2: Saturated glow core + ctx.beginPath(); + pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size); + ctx.moveTo(pt.x, pt.y); + for (let i = 1; i < activePath.length; i++) { + pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size); + ctx.lineTo(pt.x, pt.y); + } + ctx.strokeStyle = "#f97316"; // Vibrant orange + ctx.lineWidth = 8; + ctx.shadowBlur = 12; + ctx.shadowColor = "#ea580c"; + ctx.stroke(); + + // Pass 3: High-intensity white core + ctx.beginPath(); + pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size); + ctx.moveTo(pt.x, pt.y); + for (let i = 1; i < activePath.length; i++) { + pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size); + ctx.lineTo(pt.x, pt.y); + } + ctx.strokeStyle = "#fff7ed"; // Cream white + ctx.lineWidth = 3; + ctx.shadowBlur = 0; // Turn off shadows for the core + ctx.stroke(); + + // 3. Energy Particle Pulse animation tracing the road + const segments: { + start: { x: number; y: number }; + end: { x: number; y: number }; + len: number; + }[] = []; + let totalLen = 0; + for (let i = 0; i < activePath.length - 1; i++) { + const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size); + const p2 = worldToCanvas( + activePath[i + 1]!.x, + activePath[i + 1]!.z, + size, + ); + const len = Math.sqrt( + Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2), + ); + segments.push({ start: p1, end: p2, len }); + totalLen += len; + } + + if (totalLen > 0) { + const targetLen = totalLen * animTimeRef.current; + let currentLen = 0; + let dotPt = segments[0]!.start; + + for (const seg of segments) { + if (currentLen + seg.len >= targetLen) { + const ratio = (targetLen - currentLen) / seg.len; + dotPt = { + x: seg.start.x + (seg.end.x - seg.start.x) * ratio, + y: seg.start.y + (seg.end.y - seg.start.y) * ratio, + }; + break; + } + currentLen += seg.len; + } + + // Draw multiple glowing pulses along the path + ctx.beginPath(); + ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI); + ctx.fillStyle = "#ffffff"; + ctx.shadowBlur = 15; + ctx.shadowColor = "#f97316"; + ctx.fill(); + ctx.shadowBlur = 0; + } + } + + // 4. Draw Snap Markers (Start and End) + if (destPosSnapped) { + const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size); + const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4; + + // Pulse ring + ctx.beginPath(); + ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI); + ctx.strokeStyle = "rgba(249, 115, 22, 0.4)"; + ctx.lineWidth = 3; + ctx.stroke(); + + // Solid target core + ctx.beginPath(); + ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI); + ctx.fillStyle = "#ea580c"; // Deep target orange + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + + if (startPosSnapped) { + const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size); + + // Start Marker (Player Arrow/Dot) + ctx.beginPath(); + ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI); + ctx.fillStyle = "#0ea5e9"; // Cool cyberpunk sky blue + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2.5; + ctx.fill(); + ctx.stroke(); + + // Tech details + ctx.beginPath(); + ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + } + + // 5. Update WebGL Texture + if (textureRef.current) { + textureRef.current.needsUpdate = true; + } + }; + + // 60 FPS animation ticker + useEffect(() => { + let animId: number; + const tick = () => { + animTimeRef.current += 0.004; // Slow, premium sweep speed + if (animTimeRef.current > 1) animTimeRef.current = 0; + + draw(); + + animId = requestAnimationFrame(tick); + }; + animId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(animId); + }, [waypoints, startPos, destPos, bounds, mapImage]); + + return ( + + + + + + + ); +}; diff --git a/src/components/three/debug/NetTest.tsx b/src/components/three/debug/NetTest.tsx new file mode 100644 index 0000000..6b7897f --- /dev/null +++ b/src/components/three/debug/NetTest.tsx @@ -0,0 +1,24 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { createNetShader } from "@/shaders/NetShader"; + +export function NetTest(): React.JSX.Element { + const materialRef = useRef(null); + + useFrame((_, delta) => { + const timeUniform = materialRef.current?.uniforms.uTime; + if (timeUniform) timeUniform.value += delta; + }); + + return ( + + + + + ); +} diff --git a/src/data/debug/testSceneConfig.ts b/src/data/debug/testSceneConfig.ts index 6be998a..2d7f49b 100644 --- a/src/data/debug/testSceneConfig.ts +++ b/src/data/debug/testSceneConfig.ts @@ -1,4 +1,5 @@ import type { Vector3Tuple } from "@/types/three/three"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0]; export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200]; @@ -23,7 +24,7 @@ export const TEST_SCENE_TRIGGER_METALNESS = 0.5; export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65; export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045; -export const TEST_SCENE_REPAIR_ZONES = [ +export const GAME_REPAIR_ZONES = [ { mission: "ebike", label: "E-bike", @@ -43,8 +44,10 @@ export const TEST_SCENE_REPAIR_ZONES = [ position: [12, 0, -12], }, ] as const satisfies readonly { - mission: "ebike" | "pylon" | "farm"; + mission: RepairMissionId; label: string; color: string; position: Vector3Tuple; }[]; + +export const TEST_SCENE_REPAIR_ZONES = GAME_REPAIR_ZONES; diff --git a/src/data/player/playerConfig.ts b/src/data/player/playerConfig.ts index a947caa..65c0648 100644 --- a/src/data/player/playerConfig.ts +++ b/src/data/player/playerConfig.ts @@ -4,6 +4,7 @@ export const PLAYER_EYE_HEIGHT = 1.75; export const PLAYER_CAPSULE_RADIUS = 0.35; export const PLAYER_WALK_SPEED = 11; +export const PLAYER_EBIKE_SPEED = 25; export const PLAYER_AIR_CONTROL_FACTOR = 0.35; export const PLAYER_JUMP_SPEED = 9; export const PLAYER_GRAVITY = 30; diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 84fbfd4..099caab 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -6,6 +6,10 @@ import { isMissionStep, isRepairMissionId, } from "@/data/gameplay/repairMissionState"; +import { + PLAYER_EBIKE_SPEED, + PLAYER_WALK_SPEED, +} from "@/data/player/playerConfig"; import type { GameStep, MainGameState } from "@/types/game"; import { type MissionStep, @@ -18,6 +22,7 @@ import { } from "@/utils/debug/debugGameStateCookie"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; +export type PlayerMovementMode = "walk" | "ebike"; export type { MissionStep, RepairMissionId }; interface IntroState { @@ -43,6 +48,7 @@ export interface GameState { mainState: MainGameState; isCinematicPlaying: boolean; missionFlow: MissionFlowState; + player: PlayerState; intro: IntroState; ebike: MissionState & { isRepaired: boolean; @@ -59,12 +65,18 @@ export interface GameState { }; } +interface PlayerState { + movementMode: PlayerMovementMode; + currentSpeed: number; +} + interface GameActions { setMainState: (mainState: MainGameState) => void; setCinematicPlaying: (isCinematicPlaying: boolean) => void; hideDialog: () => void; setActivityCity: (activityCity: boolean) => void; setCanMove: (canMove: boolean) => void; + setPlayerMovementMode: (mode: PlayerMovementMode) => void; setIntroStep: (step: GameStep) => void; setIntroState: (intro: Partial) => void; setPlayerName: (playerName: string) => void; @@ -100,6 +112,10 @@ function isBoolean(value: unknown): value is boolean { return typeof value === "boolean"; } +function isPlayerMovementMode(value: unknown): value is PlayerMovementMode { + return value === "walk" || value === "ebike"; +} + function completeIntroState(state: GameState): GameStateUpdate { return { mainState: "ebike", @@ -234,6 +250,10 @@ function createInitialGameState(): GameState { dialogMessage: null, playerName: "", }, + player: { + movementMode: "walk", + currentSpeed: PLAYER_WALK_SPEED, + }, intro: { currentStep: "intro", dialogueAudio: null, @@ -319,6 +339,20 @@ function hydrateMissionFlowState( }; } +function hydratePlayerState(initial: PlayerState, value: unknown): PlayerState { + if (!isRecord(value)) return initial; + + return { + movementMode: isPlayerMovementMode(value.movementMode) + ? value.movementMode + : initial.movementMode, + currentSpeed: + typeof value.currentSpeed === "number" + ? value.currentSpeed + : initial.currentSpeed, + }; +} + function hydrateDebugGameState(initial: GameState, value: unknown): GameState { if (!isRecord(value)) return initial; @@ -338,6 +372,7 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState { initial.missionFlow, value.missionFlow, ), + player: hydratePlayerState(initial.player, value.player), intro: hydrateIntroState(initial.intro, value.intro), ebike: { ...ebike, @@ -385,6 +420,7 @@ function pickGameState(state: GameStore): GameState { mainState: state.mainState, isCinematicPlaying: state.isCinematicPlaying, missionFlow: state.missionFlow, + player: state.player, intro: state.intro, ebike: state.ebike, pylon: state.pylon, @@ -405,6 +441,14 @@ export const useGameStore = create()((set) => ({ set((state) => ({ missionFlow: { ...state.missionFlow, activityCity }, })), + setPlayerMovementMode: (mode) => + set((state) => ({ + player: { + ...state.player, + movementMode: mode, + currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED, + }, + })), setCanMove: (canMove) => set((state) => ({ missionFlow: { ...state.missionFlow, canMove }, diff --git a/src/pages/backgroundmap/page.tsx b/src/pages/backgroundmap/page.tsx new file mode 100644 index 0000000..958bd30 --- /dev/null +++ b/src/pages/backgroundmap/page.tsx @@ -0,0 +1,357 @@ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Canvas, useFrame, useThree } from "@react-three/fiber"; +import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei"; +import * as THREE from "three"; + +// ---------------------------------------------------------------------------- +// 1. Terrain Scene +// ---------------------------------------------------------------------------- +function TerrainScene() { + const { scene } = useGLTF("/models/terrain/terrain.glb"); + return ( + + + + + + ); +} + +// ---------------------------------------------------------------------------- +// 2. Waypoint Overlay (Debug visualization) +// ---------------------------------------------------------------------------- +function WaypointOverlay({ + waypoints, + visible, +}: { + waypoints: any[]; + visible: boolean; +}) { + if (!visible) return null; + return ( + + {waypoints.map((w) => ( + + + + + ))} + + ); +} + +// ---------------------------------------------------------------------------- +// 3. Camera Manager (Handles Orthographic Math & Downloads) +// ---------------------------------------------------------------------------- +function CameraManager({ + autoBounds, + boundsTextRef, +}: { + autoBounds: any; + boundsTextRef: React.RefObject; +}) { + const { camera, gl, scene } = useThree(); + const controlsRef = useRef(null); + + // Apply Auto-Bounds function + useEffect(() => { + const applyAutoBounds = () => { + if (camera instanceof THREE.OrthographicCamera && autoBounds) { + const width = autoBounds.maxX - autoBounds.minX; + const height = autoBounds.maxZ - autoBounds.minZ; + const centerX = (autoBounds.minX + autoBounds.maxX) / 2; + const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2; + + camera.position.set(centerX, 200, centerZ); + camera.left = -width / 2; + camera.right = width / 2; + camera.top = height / 2; + camera.bottom = -height / 2; + camera.zoom = 1; + camera.updateProjectionMatrix(); + + if (controlsRef.current) { + controlsRef.current.target.set(centerX, 0, centerZ); + controlsRef.current.update(); + } + } + }; + + (window as any).applyAutoBounds = applyAutoBounds; + // Initial apply + applyAutoBounds(); + + return () => { + delete (window as any).applyAutoBounds; + }; + }, [camera, autoBounds]); + + // Track dynamic bounds without triggering React re-renders! + useFrame(() => { + if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) { + const width = (camera.right - camera.left) / camera.zoom; + const height = (camera.top - camera.bottom) / camera.zoom; + const minX = Math.round(camera.position.x - width / 2); + const maxX = Math.round(camera.position.x + width / 2); + const minZ = Math.round(camera.position.z - height / 2); + const maxZ = Math.round(camera.position.z + height / 2); + + // Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!) + boundsTextRef.current.innerText = JSON.stringify( + { minX, maxX, minZ, maxZ }, + null, + 2, + ); + } + }); + + // Attach screenshot capture logic + useEffect(() => { + (window as any).downloadMapScreenshot = () => { + // Force an immediate render frame to ensure no UI overlays are missing + gl.render(scene, camera); + const dataUrl = gl.domElement.toDataURL("image/png"); + const a = document.createElement("a"); + a.href = dataUrl; + a.download = "/assets/gps/map_background.png"; + a.click(); + }; + return () => { + delete (window as any).downloadMapScreenshot; + }; + }, [gl, camera, scene]); + + return ( + + ); +} + +// ---------------------------------------------------------------------------- +// 4. Main Page Route Component +// ---------------------------------------------------------------------------- +export function BackgroundMapPage() { + const [waypoints, setWaypoints] = useState([]); + const [showWaypoints, setShowWaypoints] = useState(true); + const boundsTextRef = useRef(null); + + // Load road network waypoints to compute perfect GPS bounds + useEffect(() => { + const saved = localStorage.getItem("la-fabrik-waypoints"); + if (saved) { + setWaypoints(JSON.parse(saved)); + } else { + fetch("/roadNetwork.json") + .then((res) => res.json()) + .then((data) => setWaypoints(data)) + .catch(() => {}); + } + }, []); + + // Compute exact bounds that the EbikeGPSMap will use by default + const autoBounds = useMemo(() => { + if (waypoints.length === 0) return null; + const xs = waypoints.map((w) => w.x); + const zs = waypoints.map((w) => w.z); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minZ = Math.min(...zs); + const maxZ = Math.max(...zs); + + // CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE. + // If the camera is rectangular, the exported PNG will be distorted when drawn + // on the EbikeGPSMap's 1024x1024 canvas! + const width = maxX - minX; + const height = maxZ - minZ; + const maxDim = Math.max(width, height); + + const centerX = (minX + maxX) / 2; + const centerZ = (minZ + maxZ) / 2; + + const paddedDim = maxDim * 1.15 || 100; + + return { + minX: centerX - paddedDim / 2, + maxX: centerX + paddedDim / 2, + minZ: centerZ - paddedDim / 2, + maxZ: centerZ + paddedDim / 2, + }; + }, [waypoints]); + + return ( +
+ {/* + CRITICAL: The DOM element MUST be a perfect square so the resulting PNG + is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture! + */} +
+ + + + + + +
+ + {/* Premium Glassmorphic UI Dashboard */} +
+

+ GPS Map Generator +

+ +

+ 1. Cadrez votre carte (ou utilisez le Cadrage Automatique). +
+ 2. Masquez les waypoints (fond visuel seul). +
+ 3. Cliquez sur Capturer la carte. +

+ + + + + + + +
+
+ Limites Actuelles (worldBounds): +
+
+            Calcul...
+          
+
+ *Si vous décadrez à la souris, vous devrez copier ces valeurs + exactes dans la prop worldBounds de votre composant{" "} + EbikeGPSMap ! +
+
+ Astuce : Utilisez le Cadrage Automatique pour ne rien avoir à + configurer. +
+
+
+
+ ); +} diff --git a/src/pages/waypoint/page.tsx b/src/pages/waypoint/page.tsx new file mode 100644 index 0000000..a5ee7b1 --- /dev/null +++ b/src/pages/waypoint/page.tsx @@ -0,0 +1,1227 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Canvas, useFrame, useThree } from "@react-three/fiber"; +import { + useGLTF, + OrthographicCamera, + MapControls, + Line, +} from "@react-three/drei"; +import * as THREE from "three"; +import { + Trash2, + Link2, + Download, + Clipboard, + Info, + MapPin, + Map as MapIcon, +} from "lucide-react"; + +// ========================================== +// 1. Waypoint Interfaces +// ========================================== + +export interface Waypoint { + id: number; + x: number; + y: number; // height (Raycasted from terrain) + z: number; + connections: number[]; +} + +// ========================================== +// 2. Editor Scene Manager Component +// ========================================== + +interface EditorSceneProps { + waypoints: Waypoint[]; + selectedId: number | null; + hoveredNodeId: number | null; + setHoveredNodeId: (id: number | null) => void; + setDragStartNodeId: (id: number | null) => void; + dragStartNodeId: number | null; + hoverPointRef: React.MutableRefObject; + handleTerrainClick: (point: THREE.Vector3) => void; + handleSelectNode: (id: number) => void; + selectedConnection: { idA: number; idB: number } | null; + setSelectedConnection: (conn: { idA: number; idB: number } | null) => void; + hoveredConnection: { idA: number; idB: number } | null; + setHoveredConnection: (conn: { idA: number; idB: number } | null) => void; +} + +const EditorScene: React.FC = ({ + waypoints, + selectedId, + hoveredNodeId, + setHoveredNodeId, + setDragStartNodeId, + dragStartNodeId, + hoverPointRef, + handleTerrainClick, + handleSelectNode, + selectedConnection, + setSelectedConnection, + hoveredConnection, + setHoveredConnection, +}) => { + const { scene } = useGLTF("/models/terrain/terrain.glb"); + const { raycaster, pointer, camera } = useThree(); + const groupRef = useRef(null); + const rubberLineRef = useRef(null); + const rubberLineInstance = React.useMemo(() => new THREE.Line(), []); + + // Mirror reactive props inside Refs to guarantee useFrame loop never closes over stale state + const hoveredNodeIdRef = useRef(null); + const dragStartNodeIdRef = useRef(null); + const waypointsRef = useRef([]); + + useEffect(() => { + hoveredNodeIdRef.current = hoveredNodeId; + }, [hoveredNodeId]); + + useEffect(() => { + dragStartNodeIdRef.current = dragStartNodeId; + }, [dragStartNodeId]); + + useEffect(() => { + waypointsRef.current = waypoints; + }, [waypoints]); + + // Continuously raycast from mouse position to terrain and waypoints to detect hovers during drag + useFrame(() => { + if (!groupRef.current) return; + + raycaster.setFromCamera(pointer, camera); + const intersects = raycaster.intersectObjects( + groupRef.current.children, + true, + ); + + // Find waypoint sphere hover (only trigger React state update if hovered ID changes) + const sphereIntersect = intersects.find( + (item) => item.object.name && item.object.name.startsWith("waypoint-"), + ); + if (sphereIntersect) { + const nodeId = Number( + sphereIntersect.object.name.replace("waypoint-", ""), + ); + if (hoveredNodeIdRef.current !== nodeId) { + setHoveredNodeId(nodeId); + } + } else { + if (hoveredNodeIdRef.current !== null) { + setHoveredNodeId(null); + } + } + + // Find terrain mesh hover + const terrainIntersect = intersects.find( + (item) => item.object.name && !item.object.name.startsWith("waypoint-"), + ); + const activeTerrainIntersect = + terrainIntersect || intersects.find((item) => !item.object.name); + + if (activeTerrainIntersect && activeTerrainIntersect.point) { + const point = activeTerrainIntersect.point; + hoverPointRef.current = point.clone(); + + // 1. Bypass React state: Update HTML Floating Panel directly for 0ms lag + const coordsPanel = document.getElementById("coords-panel"); + if (coordsPanel) { + coordsPanel.innerText = `X: ${point.x.toFixed(2)} | Y (Raycast): ${point.y.toFixed(2)} | Z: ${point.z.toFixed(2)}`; + } + + // 2. Bypass React state: Update pink rubber band line dynamically in WebGL + const activeDragId = dragStartNodeIdRef.current; + if (activeDragId !== null && rubberLineRef.current) { + rubberLineRef.current.visible = true; + const startNode = waypointsRef.current.find( + (w) => w.id === activeDragId, + ); + if (startNode) { + rubberLineRef.current.geometry.setFromPoints([ + new THREE.Vector3(startNode.x, startNode.y + 0.4, startNode.z), + new THREE.Vector3(point.x, point.y + 0.4, point.z), + ]); + } + } else if (rubberLineRef.current) { + rubberLineRef.current.visible = false; + } + } else { + if (rubberLineRef.current) { + rubberLineRef.current.visible = false; + } + } + }); + + return ( + + {/* 1. Terrain Mesh (Raycasted for adding/dragging) */} + { + e.stopPropagation(); + // Only click-to-create a new node if they are not actively dragging a link + if (dragStartNodeId === null && e.point) { + handleTerrainClick(e.point); + } + }} + /> + + {/* 2. Drag Rubber Band Preview Line (WebGL optimized) */} + + + + + + {/* 3. Render Established Connections */} + + + {/* 4. Render Waypoint Node Spheres */} + + + ); +}; + +// ========================================== +// 3. Grid Visualizer & Helpers +// ========================================== + +interface WaypointMarkersProps { + waypoints: Waypoint[]; + selectedId: number | null; + onSelect: (id: number) => void; + hoveredNodeId: number | null; + setHoveredNodeId: (id: number | null) => void; + setDragStartNodeId: (id: number | null) => void; +} + +const WaypointMarkers: React.FC = ({ + waypoints, + selectedId, + onSelect, + hoveredNodeId, + setHoveredNodeId, + setDragStartNodeId, +}) => { + return ( + + {waypoints.map((wp) => { + const isSelected = wp.id === selectedId; + const isHovered = wp.id === hoveredNodeId; + + let color = "#3b82f6"; // Standard blue + let scale = 1.0; + + if (isSelected) { + color = "#ff0055"; // Pink-red for selected + scale = 1.5; + } else if (isHovered) { + color = "#60a5fa"; // Bright blue for hovered + scale = 1.25; + } + + return ( + { + e.stopPropagation(); + setHoveredNodeId(wp.id); + }} + onPointerOut={() => { + setHoveredNodeId(null); + }} + onPointerDown={(e: any) => { + e.stopPropagation(); + if (e.button === 0) { + // Left click start drag link connection + setDragStartNodeId(wp.id); + } else if (e.button === 2) { + // Right click select waypoint + onSelect(wp.id); + } + }} + > + {/* Core Marker Node */} + + + + + + {/* Ring indicator */} + + + + + + ); + })} + + ); +}; + +interface ConnectionLinesProps { + waypoints: Waypoint[]; + selectedConnection: { idA: number; idB: number } | null; + setSelectedConnection: (conn: { idA: number; idB: number } | null) => void; + hoveredConnection: { idA: number; idB: number } | null; + setHoveredConnection: (conn: { idA: number; idB: number } | null) => void; +} + +const ConnectionLines: React.FC = ({ + waypoints, + selectedConnection, + setSelectedConnection, + hoveredConnection, + setHoveredConnection, +}) => { + // Generate pairs of lines + const lines = React.useMemo(() => { + const list: [THREE.Vector3, THREE.Vector3, number, number][] = []; + const drawn = new Set(); + + waypoints.forEach((wp) => { + wp.connections.forEach((connId) => { + const other = waypoints.find((w) => w.id === connId); + if (other) { + const key = + wp.id < other.id ? `${wp.id}-${other.id}` : `${other.id}-${wp.id}`; + if (!drawn.has(key)) { + drawn.add(key); + list.push([ + new THREE.Vector3(wp.x, wp.y + 0.4, wp.z), + new THREE.Vector3(other.x, other.y + 0.4, other.z), + wp.id, + other.id, + ]); + } + } + }); + }); + return list; + }, [waypoints]); + + return ( + + {lines.map(([start, end, idA, idB]) => { + const isSelected = + selectedConnection && + ((selectedConnection.idA === idA && selectedConnection.idB === idB) || + (selectedConnection.idA === idB && selectedConnection.idB === idA)); + const isHovered = + hoveredConnection && + ((hoveredConnection.idA === idA && hoveredConnection.idB === idB) || + (hoveredConnection.idA === idB && hoveredConnection.idB === idA)); + + let color = "#10b981"; // Emerald green + let lineWidth = 3; + + if (isSelected) { + color = "#f59e0b"; // Amber yellow for selected connection + lineWidth = 5.0; + } else if (isHovered) { + color = "#60a5fa"; // Bright blue for hovered connection + lineWidth = 4.5; + } + + return ( + { + e.stopPropagation(); + setHoveredConnection({ idA, idB }); + }} + onPointerOut={(e) => { + e.stopPropagation(); + setHoveredConnection(null); + }} + onClick={(e) => { + e.stopPropagation(); + console.log( + `[Lien 3D] Sélectionné: Point ${idA} <-> Point ${idB}`, + ); + setSelectedConnection({ idA, idB }); + }} + /> + ); + })} + + ); +}; + +// ========================================== +// 4. Main Waypoint Editor Page Component +// ========================================== + +export const WaypointEditorPage: React.FC = () => { + const [waypoints, setWaypoints] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + + // Selection / Hover states for 3D paths/connections + const [selectedConnection, setSelectedConnection] = useState<{ + idA: number; + idB: number; + } | null>(null); + const [hoveredConnection, setHoveredConnection] = useState<{ + idA: number; + idB: number; + } | null>(null); + + // Helper function to handle connection selection and reset node selection + const handleSelectConnection = ( + conn: { idA: number; idB: number } | null, + ) => { + if (conn) { + console.log( + `[Sélection] Liaison active sélectionnée : Point ${conn.idA} <-> Point ${conn.idB}`, + ); + setSelectedId(null); // Clear selected node + } + setSelectedConnection(conn); + }; + + // Mutable ref for high frequency raycast updates to bypass React rendering loop + const hoverPointRef = useRef(null); + + // Connection / Drag states + const [dragStartNodeId, setDragStartNodeId] = useState(null); + const [isConnectingMode, setIsConnectingMode] = useState(false); + const [activeConnectionStartId, setActiveConnectionStartId] = useState< + number | null + >(null); + + // Load from localstorage on mount + useEffect(() => { + console.log( + "[Initialisation] Chargement des waypoints depuis localStorage...", + ); + const saved = localStorage.getItem("la-fabrik-waypoints"); + if (saved) { + try { + const list = JSON.parse(saved); + console.log( + `[Initialisation] ${list.length} waypoints chargés avec succès !`, + ); + setWaypoints(list); + } catch (e) { + console.error( + "[Initialisation] Erreur de parsing du stockage local", + e, + ); + } + } else { + console.log( + "[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide.", + ); + } + }, []); + + // Save to localstorage when waypoints change + const saveWaypoints = (list: Waypoint[]) => { + setWaypoints(list); + localStorage.setItem("la-fabrik-waypoints", JSON.stringify(list)); + }; + + // Delete a specific connection (break the link) + const deleteSelectedConnection = (idA: number, idB: number) => { + console.log( + `[Liaisons] Suppression définitive du lien : Point ${idA} <-> Point ${idB}`, + ); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints.map((wp) => { + if (wp.id === idA) { + return { + ...wp, + connections: wp.connections.filter((cId) => cId !== idB), + }; + } + if (wp.id === idB) { + return { + ...wp, + connections: wp.connections.filter((cId) => cId !== idA), + }; + } + return wp; + }); + localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList)); + return updatedList; + }); + setSelectedConnection(null); + }; + + // Listen for global keyboard shortcuts (e.g. Delete node or connection) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const activeEl = document.activeElement; + if ( + activeEl && + (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA") + ) { + return; + } + + if (e.key === "Delete" || e.key === "Backspace") { + console.log(`[Hotkey] Touche '${e.key}' détectée.`); + if (selectedId !== null) { + console.log( + `[Hotkey] Touche de suppression activée sur le Point sélectionné : ID = ${selectedId}`, + ); + handleDeleteNode(selectedId); + } else if (selectedConnection !== null) { + console.log( + `[Hotkey] Touche de suppression activée sur la Liaison sélectionnée : ${selectedConnection.idA} <-> ${selectedConnection.idB}`, + ); + deleteSelectedConnection( + selectedConnection.idA, + selectedConnection.idB, + ); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [selectedId, selectedConnection, waypoints]); + + // Add a new waypoint + const handleTerrainClick = (point: THREE.Vector3) => { + if (isConnectingMode) { + console.log( + "[Mode Connexion] Clic sur terrain vide. Annulation du mode liaison.", + ); + setIsConnectingMode(false); + setActiveConnectionStartId(null); + return; + } + + setWaypoints((currentWaypoints) => { + const nextId = + currentWaypoints.length > 0 + ? Math.max(...currentWaypoints.map((w) => w.id)) + 1 + : 1; + const newWp: Waypoint = { + id: nextId, + x: Number(point.x.toFixed(2)), + y: Number(point.y.toFixed(2)), + z: Number(point.z.toFixed(2)), + connections: [], + }; + + console.log( + `[Création] Nouveau Point déposé : ID = ${nextId} | Coordonnées : (${newWp.x}, ${newWp.y}, ${newWp.z})`, + ); + const newList = [...currentWaypoints, newWp]; + localStorage.setItem("la-fabrik-waypoints", JSON.stringify(newList)); + setTimeout(() => { + setSelectedConnection(null); + setSelectedId(nextId); + }, 0); + return newList; + }); + }; + + // Select node or handle connections (toggles connections if they already exist) + const handleSelectNode = (id: number) => { + setSelectedConnection(null); // Reset connection selection + + if (isConnectingMode && activeConnectionStartId !== null) { + if (activeConnectionStartId === id) { + console.log( + "[Mode Connexion] Tentative de liaison sur soi-même. Annulation.", + ); + setIsConnectingMode(false); + setActiveConnectionStartId(null); + return; + } + + console.log( + `[Mode Connexion] Création manuelle d'un lien : Point ${activeConnectionStartId} <-> Point ${id}`, + ); + toggleConnection(activeConnectionStartId, id); + setIsConnectingMode(false); + setActiveConnectionStartId(null); + setSelectedId(id); + } else { + console.log(`[Sélection] Point sélectionné : ID = ${id}`); + setSelectedId(id); + } + }; + + // Toggle connection between two waypoint IDs (using functional state to prevent stale closures) + const toggleConnection = (idA: number, idB: number) => { + console.log(`[Liaisons] toggleConnection(Point ${idA}, Point ${idB})`); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints.map((wp) => { + if (wp.id === idA) { + const alreadyLinked = wp.connections.includes(idB); + console.log( + `[Liaisons] Point ${idA} : ${alreadyLinked ? "Suppression" : "Ajout"} de la liaison vers Point ${idB}`, + ); + const conns = alreadyLinked + ? wp.connections.filter((cId) => cId !== idB) + : [...wp.connections, idB]; + return { ...wp, connections: conns }; + } + if (wp.id === idB) { + const alreadyLinked = wp.connections.includes(idA); + console.log( + `[Liaisons] Point ${idB} : ${alreadyLinked ? "Suppression" : "Ajout"} de la liaison vers Point ${idA}`, + ); + const conns = alreadyLinked + ? wp.connections.filter((cId) => cId !== idA) + : [...wp.connections, idA]; + return { ...wp, connections: conns }; + } + return wp; + }); + localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList)); + return updatedList; + }); + }; + + // Global pointer up handler for completing link drags (releases on empty space create & connect a node) + const handleGlobalPointerUp = () => { + if (dragStartNodeId !== null) { + if (hoveredNodeId !== null && hoveredNodeId !== dragStartNodeId) { + console.log( + `[Drag&Drop] Relâchement sur le Point existant : ID = ${hoveredNodeId}. Création/Toggling du lien.`, + ); + toggleConnection(dragStartNodeId, hoveredNodeId); + } else if (hoverPointRef.current !== null) { + const point = hoverPointRef.current; + setWaypoints((currentWaypoints) => { + const nextId = + currentWaypoints.length > 0 + ? Math.max(...currentWaypoints.map((w) => w.id)) + 1 + : 1; + const newWp: Waypoint = { + id: nextId, + x: Number(point.x.toFixed(2)), + y: Number(point.y.toFixed(2)), + z: Number(point.z.toFixed(2)), + connections: [dragStartNodeId], + }; + + console.log( + `[Drag&Drop] Relâchement sur zone vide. Création automatique du Point ${nextId} aux coordonnées (${newWp.x}, ${newWp.y}, ${newWp.z}) et liaison mutuelle avec le Point ${dragStartNodeId}`, + ); + + const updatedList = currentWaypoints.map((wp) => { + if (wp.id === dragStartNodeId) { + return { + ...wp, + connections: wp.connections.includes(nextId) + ? wp.connections + : [...wp.connections, nextId], + }; + } + return wp; + }); + + const finalList = [...updatedList, newWp]; + localStorage.setItem( + "la-fabrik-waypoints", + JSON.stringify(finalList), + ); + setTimeout(() => { + setSelectedConnection(null); + setSelectedId(nextId); + }, 0); + return finalList; + }); + } else { + setSelectedId(dragStartNodeId); + } + setDragStartNodeId(null); + } + }; + + // Delete current selected node + const handleDeleteNode = (id: number) => { + console.log( + `[Suppression] Action de suppression définitive du Point : ID = ${id}`, + ); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints + .filter((wp) => wp.id !== id) + .map((wp) => ({ + ...wp, + connections: wp.connections.filter((cId) => cId !== id), + })); + console.log( + `[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`, + ); + localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList)); + return updatedList; + }); + setSelectedId((currentSelected) => + currentSelected === id ? null : currentSelected, + ); + }; + + // Connect Mode Trigger + const startConnecting = (id: number) => { + console.log( + `[Mode Connexion] Démarrage mode connexion manuelle depuis Point ID = ${id}`, + ); + setIsConnectingMode(true); + setActiveConnectionStartId(id); + }; + + // Clear all waypoints + const handleClearAll = () => { + if ( + window.confirm( + "Voulez-vous vraiment TOUT supprimer ? Cette action est irréversible.", + ) + ) { + console.log( + "[Action] Suppression complète et définitive de tous les points de la carte.", + ); + saveWaypoints([]); + setSelectedId(null); + setSelectedConnection(null); + } + }; + + // Copy network JSON to clipboard + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(JSON.stringify(waypoints, null, 2)); + alert("JSON copié dans le presse-papier !"); + }; + + // Download network JSON file + const handleDownload = () => { + const dataStr = + "data:text/json;charset=utf-8," + + encodeURIComponent(JSON.stringify(waypoints, null, 2)); + const downloadAnchor = document.createElement("a"); + downloadAnchor.setAttribute("href", dataStr); + downloadAnchor.setAttribute("download", "roadNetwork.json"); + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + downloadAnchor.remove(); + }; + + const selectedNode = waypoints.find((w) => w.id === selectedId); + + return ( +
+ {/* 1. Header Navigation */} +
+
+ +

La Fabrik — Waypoint Network Editor

+
+
+ + +
+
+ +
+ {/* 2. Left sidebar: Nodes manager */} + + + {/* 3. Three.js Canvas */} +
e.preventDefault()} + > + {/* Active Hover point details (DOM-optimized to prevent high-frequency React renders) */} +
+ Survolez le terrain... +
+ + {isConnectingMode && ( +
+ Mode Connexion Actif : Cliquez sur le deuxième + waypoint pour lier le point {activeConnectionStartId}. +
+ )} + + {dragStartNodeId !== null && ( +
+ Relier le point {dragStartNodeId}... Glissez + et relâchez sur un autre point. +
+ )} + + + {/* Top-down isometric / orthographic camera */} + + + + + + {/* Locked Orbit/Map controls (Locked rotation for strict top-down, disabled during link dragging to prevent panning) */} + + + {/* Load Terrain, Track hover & draw drag previews/spheres cleanly using full-rate raycasting scene */} + + +
+
+
+ ); +}; + +// ========================================== +// Styles (Premium Dark Glassmorphism) +// ========================================== + +const styles = { + container: { + width: "100vw", + height: "100vh", + display: "flex", + flexDirection: "column", + backgroundColor: "#0f172a", // deep slate + color: "#f8fafc", + fontFamily: "system-ui, -apple-system, sans-serif", + overflow: "hidden", + } as React.CSSProperties, + header: { + height: "64px", + backgroundColor: "rgba(30, 41, 59, 0.75)", + borderBottom: "1px solid rgba(255, 255, 255, 0.08)", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "0 24px", + backdropFilter: "blur(12px)", + zIndex: 10, + } as React.CSSProperties, + logoGroup: { + display: "flex", + alignItems: "center", + gap: "12px", + } as React.CSSProperties, + logoIcon: { + color: "#3b82f6", + }, + logoText: { + fontSize: "18px", + fontWeight: 600, + letterSpacing: "-0.02em", + margin: 0, + } as React.CSSProperties, + headerControls: { + display: "flex", + gap: "12px", + } as React.CSSProperties, + primaryButton: { + backgroundColor: "#3b82f6", + color: "#ffffff", + border: "none", + borderRadius: "8px", + padding: "8px 16px", + fontSize: "14px", + fontWeight: 500, + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: "8px", + boxShadow: "0 4px 12px rgba(59, 130, 246, 0.3)", + transition: "all 0.2s", + } as React.CSSProperties, + secondaryButton: { + backgroundColor: "rgba(255, 255, 255, 0.05)", + color: "#f8fafc", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + padding: "8px 16px", + fontSize: "14px", + fontWeight: 500, + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: "8px", + transition: "all 0.2s", + } as React.CSSProperties, + mainArea: { + flex: 1, + display: "flex", + overflow: "hidden", + position: "relative", + } as React.CSSProperties, + sidebar: { + width: "360px", + backgroundColor: "rgba(15, 23, 42, 0.85)", + borderRight: "1px solid rgba(255, 255, 255, 0.08)", + display: "flex", + flexDirection: "column", + padding: "20px", + backdropFilter: "blur(16px)", + zIndex: 5, + } as React.CSSProperties, + sidebarHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "16px", + } as React.CSSProperties, + sidebarTitle: { + fontSize: "16px", + fontWeight: 600, + margin: 0, + } as React.CSSProperties, + clearButton: { + backgroundColor: "transparent", + color: "#ef4444", + border: "none", + cursor: "pointer", + fontSize: "12px", + display: "flex", + alignItems: "center", + gap: "4px", + } as React.CSSProperties, + nodesList: { + flex: 1, + overflowY: "auto", + display: "flex", + flexDirection: "column", + gap: "8px", + paddingRight: "4px", + marginBottom: "20px", + } as React.CSSProperties, + emptyState: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "200px", + color: "#64748b", + textAlign: "center", + padding: "0 20px", + } as React.CSSProperties, + emptyIcon: { + marginBottom: "12px", + color: "#475569", + }, + emptyText: { + fontSize: "13px", + lineHeight: "1.5", + margin: 0, + } as React.CSSProperties, + nodeItem: (isSelected: boolean): React.CSSProperties => ({ + padding: "12px", + borderRadius: "10px", + border: `1px solid ${isSelected ? "rgba(59, 130, 246, 0.4)" : "rgba(255, 255, 255, 0.05)"}`, + backgroundColor: isSelected + ? "rgba(59, 130, 246, 0.12)" + : "rgba(255, 255, 255, 0.02)", + cursor: "pointer", + transition: "all 0.2s", + display: "flex", + flexDirection: "column", + gap: "4px", + }), + nodeInfo: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + } as React.CSSProperties, + nodeBadge: { + fontSize: "12px", + fontWeight: 600, + color: "#3b82f6", + } as React.CSSProperties, + nodeCoords: { + fontSize: "11px", + color: "#94a3b8", + } as React.CSSProperties, + nodeSubinfo: { + fontSize: "11px", + color: "#64748b", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + } as React.CSSProperties, + detailsCard: { + backgroundColor: "rgba(255, 255, 255, 0.03)", + border: "1px solid rgba(255, 255, 255, 0.06)", + borderRadius: "12px", + padding: "16px", + display: "flex", + flexDirection: "column", + gap: "12px", + } as React.CSSProperties, + detailsTitle: { + fontSize: "14px", + fontWeight: 600, + margin: 0, + } as React.CSSProperties, + detailsGrid: { + display: "flex", + flexDirection: "column", + gap: "8px", + fontSize: "13px", + } as React.CSSProperties, + detailsRow: { + display: "flex", + justifyContent: "space-between", + borderBottom: "1px solid rgba(255, 255, 255, 0.04)", + paddingBottom: "4px", + } as React.CSSProperties, + detailsActions: { + display: "flex", + gap: "10px", + marginTop: "6px", + } as React.CSSProperties, + connectButton: (isActive: boolean): React.CSSProperties => ({ + flex: 1, + backgroundColor: isActive ? "#ff0055" : "rgba(16, 185, 129, 0.15)", + color: isActive ? "#ffffff" : "#10b981", + border: `1px solid ${isActive ? "#ff0055" : "rgba(16, 185, 129, 0.3)"}`, + borderRadius: "8px", + padding: "8px 12px", + fontSize: "13px", + fontWeight: 500, + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "6px", + boxShadow: isActive ? "0 4px 12px rgba(255, 0, 85, 0.3)" : "none", + transition: "all 0.2s", + }), + deleteButton: { + backgroundColor: "rgba(239, 68, 68, 0.15)", + color: "#ef4444", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "8px", + padding: "8px 12px", + fontSize: "13px", + fontWeight: 500, + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "6px", + transition: "all 0.2s", + } as React.CSSProperties, + canvasContainer: { + flex: 1, + position: "relative", + } as React.CSSProperties, + coordsFloating: { + position: "absolute", + top: "16px", + left: "16px", + backgroundColor: "rgba(15, 23, 42, 0.85)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "8px", + padding: "8px 14px", + fontSize: "12px", + color: "#94a3b8", + backdropFilter: "blur(8px)", + pointerEvents: "none", + zIndex: 1, + } as React.CSSProperties, + connectingBanner: { + position: "absolute", + top: "16px", + left: "50%", + transform: "translateX(-50%)", + backgroundColor: "#ff0055", + color: "#ffffff", + borderRadius: "8px", + padding: "10px 20px", + fontSize: "13px", + fontWeight: 500, + boxShadow: "0 4px 20px rgba(255, 0, 85, 0.4)", + display: "flex", + alignItems: "center", + gap: "8px", + pointerEvents: "none", + zIndex: 1, + } as React.CSSProperties, +}; diff --git a/src/pathfinding/AStar.ts b/src/pathfinding/AStar.ts new file mode 100644 index 0000000..1e6573f --- /dev/null +++ b/src/pathfinding/AStar.ts @@ -0,0 +1,131 @@ +import { Grid } from "./Grid"; +import type { GridNode, Position } from "./types"; + +/** + * Calculates the octile heuristic distance between two nodes. + * Ideal for 8-directional grid movement. + */ +function getOctileDistance(nodeA: GridNode, nodeB: GridNode): number { + const dx = Math.abs(nodeA.x - nodeB.x); + const dy = Math.abs(nodeA.y - nodeB.y); + + const D = 1; // Orthogonal movement cost + const D2 = 1.414; // Diagonal movement cost (approx Math.sqrt(2)) + + return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy); +} + +/** + * Finds the shortest path between start and end positions on the grid. + * Returns an array of Positions representing the path, or an empty array if no path is found. + */ +export function findPath( + grid: Grid, + startPos: Position, + endPos: Position, + allowDiagonals: boolean = true, +): Position[] { + grid.reset(); + + const startNode = grid.getNode( + Math.floor(startPos.x), + Math.floor(startPos.y), + ); + const endNode = grid.getNode(Math.floor(endPos.x), Math.floor(endPos.y)); + + if (!startNode || !endNode) { + return []; + } + + // If the destination node itself is blocked, we try to find the nearest walkable neighbor + if (!endNode.walkable) { + const endNeighbors = grid.getNeighbors(endNode, allowDiagonals); + if (endNeighbors.length === 0) { + return []; + } + // Set destination to the closest walkable neighbor + let closestNeighbor = endNeighbors[0]!; + let minDist = getOctileDistance(startNode, closestNeighbor); + for (let i = 1; i < endNeighbors.length; i++) { + const neighbor = endNeighbors[i]!; + const dist = getOctileDistance(startNode, neighbor); + if (dist < minDist) { + minDist = dist; + closestNeighbor = neighbor; + } + } + // Reroute to that walkable neighbor + return findPath( + grid, + startPos, + { x: closestNeighbor.x, y: closestNeighbor.y }, + allowDiagonals, + ); + } + + const openSet: GridNode[] = [startNode]; + const closedSet = new Set(); + + startNode.g = 0; + startNode.h = getOctileDistance(startNode, endNode); + startNode.f = startNode.h; + + while (openSet.length > 0) { + // Find the node in openSet with the lowest f value + let lowIndex = 0; + for (let i = 1; i < openSet.length; i++) { + const node = openSet[i]!; + const lowNode = openSet[lowIndex]!; + if (node.f < lowNode.f) { + lowIndex = i; + } + } + + const currentNode = openSet[lowIndex]!; + + // Check if we reached the destination + if (currentNode === endNode) { + const path: Position[] = []; + let temp: GridNode | null = currentNode; + while (temp !== null) { + path.push({ x: temp.x, y: temp.y }); + temp = temp.parent; + } + return path.reverse(); + } + + // Remove currentNode from openSet and add to closedSet + openSet.splice(lowIndex, 1); + closedSet.add(currentNode); + + const neighbors = grid.getNeighbors(currentNode, allowDiagonals); + + for (const neighbor of neighbors) { + if (closedSet.has(neighbor)) { + continue; + } + + // Calculate cost to move to this neighbor (1 for orthogonal, 1.414 for diagonal) + const isDiagonal = + neighbor.x !== currentNode.x && neighbor.y !== currentNode.y; + const moveCost = isDiagonal ? 1.414 : 1; + const tentativeG = currentNode.g + moveCost; + + const neighborInOpenSet = openSet.includes(neighbor); + + if (!neighborInOpenSet || tentativeG < neighbor.g) { + neighbor.parent = currentNode; + neighbor.g = tentativeG; + neighbor.h = getOctileDistance(neighbor, endNode); + neighbor.f = neighbor.g + neighbor.h; + + if (!neighborInOpenSet) { + openSet.push(neighbor); + } + } + } + } + + // Return empty if no path is found + return []; +} diff --git a/src/pathfinding/GPSMinimap.tsx b/src/pathfinding/GPSMinimap.tsx new file mode 100644 index 0000000..dfefc70 --- /dev/null +++ b/src/pathfinding/GPSMinimap.tsx @@ -0,0 +1,234 @@ +import React, { useRef, useEffect, useState, useMemo } from "react"; +import * as THREE from "three"; +import { useGPS } from "./useGPS"; +import type { WorldBounds } from "./useGPS"; + +// ========================================== +// 1. Premium 2D HUD GPS Overlay Component +// ========================================== + +export interface GPSMinimapHUDProps { + bwMaskUrl: string; + colorMapUrl: string; + gridWidth: number; + gridHeight: number; + worldBounds: WorldBounds; + playerPos: { x: number; z: number }; + destPos?: { x: number; z: number }; + size?: number; // Size of HUD in pixels +} + +/** + * A beautiful, glassmorphic 2D HUD overlay that renders the GPS Minimap + * in the corner of the screen. + */ +export const GPSMinimapHUD: React.FC = ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + playerPos, + destPos, + size = 200, +}) => { + const canvasRef = useRef(null); + + const gpsOptions = useMemo( + () => ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + }), + [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds], + ); + + const { calculateWorldPath, renderGPSToCanvas, loading, error } = + useGPS(gpsOptions); + + useEffect(() => { + if (loading || error || !canvasRef.current) return; + + // Calculate A* path in world coordinates + const path = destPos ? calculateWorldPath(playerPos, destPos) : []; + + // Render path onto HUD canvas + renderGPSToCanvas(canvasRef.current, path, playerPos, destPos, { + pathColor: "#3b82f6", // Premium vibrant blue + pathWidth: 5, + playerColor: "#ef4444", // Hot red for player + playerSize: 6, + destColor: "#10b981", // Emerald green for destination + destSize: 6, + }); + }, [ + playerPos, + destPos, + loading, + error, + calculateWorldPath, + renderGPSToCanvas, + ]); + + return ( +
+ {loading &&
Initializing GPS...
} + {error && ( +
+ GPS Error: {error} +
+ )} + + {!loading && !error && ( + + )} +
+ ); +}; + +// ========================================== +// 2. 3D Handlebar Screen Mesh Component (R3F) +// ========================================== + +export interface GPSBikeScreenProps { + bwMaskUrl: string; + colorMapUrl: string; + gridWidth: number; + gridHeight: number; + worldBounds: WorldBounds; + playerPos: { x: number; z: number }; + destPos?: { x: number; z: number }; + width?: number; // 3D Plane Width + height?: number; // 3D Plane Height +} + +/** + * A Three.js 3D plane mesh that renders the GPS dynamically as a CanvasTexture. + * This can be directly attached to the bike's handlebars in your 3D world. + */ +export const GPSBikeScreen: React.FC = ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + playerPos, + destPos, + width = 0.4, + height = 0.4, +}) => { + // Offscreen canvas to render the GPS texture onto + const [offscreenCanvas] = useState(() => { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 512; + return canvas; + }); + + const textureRef = useRef(null); + + const gpsOptions = useMemo( + () => ({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + }), + [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds], + ); + + const { calculateWorldPath, renderGPSToCanvas, loading } = useGPS(gpsOptions); + + useEffect(() => { + if (loading) return; + + // Calculate A* path + const path = destPos ? calculateWorldPath(playerPos, destPos) : []; + + // Render path onto our offscreen canvas + renderGPSToCanvas(offscreenCanvas, path, playerPos, destPos, { + pathColor: "#60a5fa", // Bright neon blue + pathWidth: 8, + playerColor: "#ff0055", // Neon pink-red for bike + playerSize: 10, + destColor: "#00ffcc", // Vibrant cyan for target + destSize: 10, + }); + + // Notify Three.js that the texture needs an update + if (textureRef.current) { + textureRef.current.needsUpdate = true; + } + }, [ + playerPos, + destPos, + loading, + calculateWorldPath, + renderGPSToCanvas, + offscreenCanvas, + ]); + + return ( + + + + + + + ); +}; + +// ========================================== +// Styles for HUD (Premium Glassmorphism) +// ========================================== + +const hudStyles = { + container: (size: number): React.CSSProperties => ({ + position: "absolute", + bottom: "24px", + right: "24px", + width: `${size}px`, + height: `${size}px`, + borderRadius: "24px", + overflow: "hidden", + border: "1px solid rgba(255, 255, 255, 0.15)", + boxShadow: + "0 8px 32px 0 rgba(0, 0, 0, 0.37), 0 0 15px rgba(59, 130, 246, 0.2)", + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + background: "rgba(15, 23, 42, 0.6)", // Sleek dark slate + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + pointerEvents: "none", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + }), + canvas: (size: number): React.CSSProperties => ({ + width: `${size}px`, + height: `${size}px`, + display: "block", + }), + statusText: { + color: "#94a3b8", + fontFamily: + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: "12px", + fontWeight: 500, + letterSpacing: "0.05em", + } as React.CSSProperties, +}; diff --git a/src/pathfinding/Grid.ts b/src/pathfinding/Grid.ts new file mode 100644 index 0000000..b0f2541 --- /dev/null +++ b/src/pathfinding/Grid.ts @@ -0,0 +1,104 @@ +import type { GridNode } from "./types"; + +export class Grid { + public width: number; + public height: number; + private nodes: GridNode[][]; + + constructor(walkableMatrix: boolean[][]) { + this.height = walkableMatrix.length; + this.width = this.height > 0 ? (walkableMatrix[0]?.length ?? 0) : 0; + this.nodes = []; + + for (let y = 0; y < this.height; y++) { + const row: GridNode[] = []; + const sourceRow = walkableMatrix[y]; + for (let x = 0; x < this.width; x++) { + row.push({ + x, + y, + walkable: sourceRow ? (sourceRow[x] ?? false) : false, + g: 0, + h: 0, + f: 0, + parent: null, + }); + } + this.nodes.push(row); + } + } + + public getNode(x: number, y: number): GridNode | null { + if (x >= 0 && x < this.width && y >= 0 && y < this.height) { + const row = this.nodes[y]; + return row ? (row[x] ?? null) : null; + } + return null; + } + + /** + * Resets g, h, f values and parents for all nodes in the grid, + * preparing it for a new A* calculation. + */ + public reset(): void { + for (let y = 0; y < this.height; y++) { + const row = this.nodes[y]; + if (!row) continue; + for (let x = 0; x < this.width; x++) { + const node = row[x]; + if (!node) continue; + node.g = 0; + node.h = 0; + node.f = 0; + node.parent = null; + } + } + } + + /** + * Retrieves neighboring nodes. Supports 8-directional movement. + */ + public getNeighbors( + node: GridNode, + allowDiagonals: boolean = true, + ): GridNode[] { + const neighbors: GridNode[] = []; + const { x, y } = node; + + // Relative coordinates of 8 neighbors + const directions = [ + { dx: 0, dy: -1, isDiagonal: false }, // N + { dx: 1, dy: 0, isDiagonal: false }, // E + { dx: 0, dy: 1, isDiagonal: false }, // S + { dx: -1, dy: 0, isDiagonal: false }, // W + ]; + + if (allowDiagonals) { + directions.push( + { dx: 1, dy: -1, isDiagonal: true }, // NE + { dx: 1, dy: 1, isDiagonal: true }, // SE + { dx: -1, dy: 1, isDiagonal: true }, // SW + { dx: -1, dy: -1, isDiagonal: true }, // NW + ); + } + + for (const dir of directions) { + const neighbor = this.getNode(x + dir.dx, y + dir.dy); + if (neighbor && neighbor.walkable) { + // Prevent corner cutting if both orthogonal neighbors are blocked + if (dir.isDiagonal) { + const ortho1 = this.getNode(x + dir.dx, y); + const ortho2 = this.getNode(x, y + dir.dy); + const isBlocked = + (!ortho1 || !ortho1.walkable) && (!ortho2 || !ortho2.walkable); + if (isBlocked) { + continue; // Skip this diagonal neighbor to avoid squeezing through corners + } + } + neighbors.push(neighbor); + } + } + + return neighbors; + } +} diff --git a/src/pathfinding/ImageToGrid.ts b/src/pathfinding/ImageToGrid.ts new file mode 100644 index 0000000..4d86fe3 --- /dev/null +++ b/src/pathfinding/ImageToGrid.ts @@ -0,0 +1,76 @@ +import { Grid } from "./Grid"; + +/** + * Loads an image from a URL. + */ +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; // Enable CORS just in case + img.onload = () => resolve(img); + img.onerror = (err) => + reject(new Error(`Failed to load image at ${url}: ${err}`)); + img.src = url; + }); +} + +/** + * Loads a B&W image and scales it to gridWidth x gridHeight. + * Higher dimensions = higher accuracy but slower pathfinding. + * Lower dimensions = extremely fast pathfinding. + * + * Walkable roads should be white (or light gray). Non-walkable areas should be black. + * + * @param imageUrl The path or URL of the B&W navigation mask. + * @param gridWidth The target width of our A* pathfinding grid. + * @param gridHeight The target height of our A* pathfinding grid. + * @param threshold Brightness threshold (0-255) above which a pixel is considered walkable (default: 128). + */ +export async function createGridFromImage( + imageUrl: string, + gridWidth: number, + gridHeight: number, + threshold: number = 128, +): Promise { + const img = await loadImage(imageUrl); + + // Create an offscreen canvas to scale and analyze the image + const canvas = document.createElement("canvas"); + canvas.width = gridWidth; + canvas.height = gridHeight; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Could not get 2D context for offscreen canvas"); + } + + // Draw and scale the image onto the canvas + ctx.drawImage(img, 0, 0, gridWidth, gridHeight); + + // Retrieve pixel data + const imgData = ctx.getImageData(0, 0, gridWidth, gridHeight); + const data = imgData.data; + + // Initialize a 2D boolean matrix representing the walkable grid + const walkableMatrix: boolean[][] = []; + + for (let y = 0; y < gridHeight; y++) { + const row: boolean[] = []; + for (let x = 0; x < gridWidth; x++) { + // Each pixel has 4 channels: R, G, B, A + const index = (y * gridWidth + x) * 4; + const r = data[index] ?? 0; + const g = data[index + 1] ?? 0; + const b = data[index + 2] ?? 0; + + // Calculate brightness (standard grayscale weighting) + const brightness = 0.299 * r + 0.587 * g + 0.114 * b; + + // If bright enough, it is a road (walkable) + row.push(brightness >= threshold); + } + walkableMatrix.push(row); + } + + return new Grid(walkableMatrix); +} diff --git a/src/pathfinding/WaypointAStar.ts b/src/pathfinding/WaypointAStar.ts new file mode 100644 index 0000000..6f49934 --- /dev/null +++ b/src/pathfinding/WaypointAStar.ts @@ -0,0 +1,146 @@ +import type { Waypoint, WaypointNode } from "./types"; + +/** + * Calculates Euclidean 3D distance between two points. + */ +function getDistance3D( + posA: { x: number; y: number; z: number }, + posB: { x: number; y: number; z: number }, +): number { + return Math.sqrt( + Math.pow(posA.x - posB.x, 2) + + Math.pow(posA.y - posB.y, 2) + + Math.pow(posA.z - posB.z, 2), + ); +} + +/** + * Finds the closest Waypoint in a list to a target 3D world position. + */ +export function findClosestWaypoint( + waypoints: Waypoint[], + pos: { x: number; y: number; z: number }, +): Waypoint | null { + if (waypoints.length === 0) return null; + + let closest = waypoints[0]!; + let minDist = getDistance3D(closest, pos); + + for (let i = 1; i < waypoints.length; i++) { + const wp = waypoints[i]!; + const dist = getDistance3D(wp, pos); + if (dist < minDist) { + minDist = dist; + closest = wp; + } + } + + return closest; +} + +/** + * Runs A* pathfinding on a network of 3D Waypoints. + * + * @param waypoints List of all waypoints in the road network. + * @param startWorldPos Player's current 3D world position. + * @param endWorldPos Targeted 3D world destination. + * @returns Array of Waypoints representing the path from start to end, or empty array if none found. + */ +export function findWaypointPath( + waypoints: Waypoint[], + startWorldPos: { x: number; y: number; z: number }, + endWorldPos: { x: number; y: number; z: number }, +): Waypoint[] { + if (waypoints.length === 0) return []; + + // 1. Find the closest starting and ending waypoints in the network + const startWp = findClosestWaypoint(waypoints, startWorldPos); + const endWp = findClosestWaypoint(waypoints, endWorldPos); + + if (!startWp || !endWp) return []; + if (startWp.id === endWp.id) return [startWp]; + + // 2. Map all waypoints to A* search nodes + const nodeMap = new Map(); + waypoints.forEach((wp) => { + nodeMap.set(wp.id, { + ...wp, + g: Infinity, + h: Infinity, + f: Infinity, + parent: null, + }); + }); + + const startNode = nodeMap.get(startWp.id)!; + const endNode = nodeMap.get(endWp.id)!; + + // 3. Initialize open and closed sets + const openSet: WaypointNode[] = [startNode]; + const closedSet = new Set(); // Set of waypoint IDs + + startNode.g = 0; + startNode.h = getDistance3D(startNode, endNode); + startNode.f = startNode.h; + + while (openSet.length > 0) { + // Find node with lowest f score + let lowIndex = 0; + for (let i = 1; i < openSet.length; i++) { + const node = openSet[i]!; + const lowNode = openSet[lowIndex]!; + if (node.f < lowNode.f) { + lowIndex = i; + } + } + + const currentNode = openSet[lowIndex]!; + + // Reached destination! Reconstruct the path + if (currentNode.id === endNode.id) { + const path: Waypoint[] = []; + let temp: WaypointNode | null = currentNode; + while (temp !== null) { + // Find corresponding raw Waypoint + const rawWp = waypoints.find((w) => w.id === temp!.id); + if (rawWp) { + path.push(rawWp); + } + temp = temp.parent; + } + return path.reverse(); + } + + // Move from open to closed set + openSet.splice(lowIndex, 1); + closedSet.add(currentNode.id); + + // Process neighbors + for (const neighborId of currentNode.connections) { + if (closedSet.has(neighborId)) continue; + + const neighborNode = nodeMap.get(neighborId); + if (!neighborNode) continue; + + // Distance from currentNode to neighbor is physical 3D distance + const tentativeG = + currentNode.g + getDistance3D(currentNode, neighborNode); + + const neighborInOpenSet = openSet.some((node) => node.id === neighborId); + + if (!neighborInOpenSet || tentativeG < neighborNode.g) { + neighborNode.parent = currentNode; + neighborNode.g = tentativeG; + neighborNode.h = getDistance3D(neighborNode, endNode); + neighborNode.f = neighborNode.g + neighborNode.h; + + if (!neighborInOpenSet) { + openSet.push(neighborNode); + } + } + } + } + + // No path found + return []; +} diff --git a/src/pathfinding/index.ts b/src/pathfinding/index.ts new file mode 100644 index 0000000..7db2412 --- /dev/null +++ b/src/pathfinding/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./Grid"; +export * from "./AStar"; +export * from "./ImageToGrid"; +export * from "./useGPS"; +export * from "./GPSMinimap"; +export * from "./WaypointAStar"; +export * from "./useWaypointGPS"; diff --git a/src/pathfinding/types.ts b/src/pathfinding/types.ts new file mode 100644 index 0000000..32ae6c2 --- /dev/null +++ b/src/pathfinding/types.ts @@ -0,0 +1,39 @@ +export interface Position { + x: number; + y: number; +} + +export interface GridNode { + x: number; + y: number; + walkable: boolean; + g: number; + h: number; + f: number; + parent: GridNode | null; +} + +export interface GridSize { + width: number; + height: number; +} + +export interface Waypoint { + id: number; + x: number; + y: number; + z: number; + connections: number[]; +} + +export interface WaypointNode { + id: number; + x: number; + y: number; + z: number; + connections: number[]; + g: number; + h: number; + f: number; + parent: WaypointNode | null; +} diff --git a/src/pathfinding/useGPS.ts b/src/pathfinding/useGPS.ts new file mode 100644 index 0000000..fbb7780 --- /dev/null +++ b/src/pathfinding/useGPS.ts @@ -0,0 +1,256 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Grid } from "./Grid"; +import { createGridFromImage } from "./ImageToGrid"; +import { findPath } from "./AStar"; +import type { Position } from "./types"; + +export interface WorldBounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +export interface UseGPSOptions { + bwMaskUrl: string; + colorMapUrl: string; + gridWidth: number; // The "width of the array pathfinding" (resolution scaling) + gridHeight: number; // The "height of the array pathfinding" + worldBounds: WorldBounds; + allowDiagonals?: boolean; +} + +export function useGPS({ + bwMaskUrl, + colorMapUrl, + gridWidth, + gridHeight, + worldBounds, + allowDiagonals = true, +}: UseGPSOptions) { + const [grid, setGrid] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Cache the images so they don't reload every frame + const colorMapImgRef = useRef(null); + + // Initialize the pathfinding grid + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + async function initGrid() { + try { + const pathfindingGrid = await createGridFromImage( + bwMaskUrl, + gridWidth, + gridHeight, + ); + + // Pre-load color map image for canvas drawing + const colorMapImg = new Image(); + colorMapImg.crossOrigin = "anonymous"; + await new Promise((resolve, reject) => { + colorMapImg.onload = resolve; + colorMapImg.onerror = reject; + colorMapImg.src = colorMapUrl; + }); + + if (active) { + setGrid(pathfindingGrid); + colorMapImgRef.current = colorMapImg; + setLoading(false); + } + } catch (err: any) { + if (active) { + setError(err.message || "Failed to initialize GPS system"); + setLoading(false); + } + } + } + + initGrid(); + + return () => { + active = false; + }; + }, [bwMaskUrl, colorMapUrl, gridWidth, gridHeight]); + + /** + * Translates 3D World coordinates (X, Z) into 2D Grid coordinates (col, row) + */ + const worldToGrid = useCallback( + (worldX: number, worldZ: number): Position => { + const { minX, maxX, minZ, maxZ } = worldBounds; + + // Calculate percentages across the bounds + const pctX = (worldX - minX) / (maxX - minX); + const pctZ = (worldZ - minZ) / (maxZ - minZ); + + // Map to grid dimensions + const gridX = Math.max( + 0, + Math.min(gridWidth - 1, Math.floor(pctX * gridWidth)), + ); + const gridY = Math.max( + 0, + Math.min(gridHeight - 1, Math.floor(pctZ * gridHeight)), + ); + + return { x: gridX, y: gridY }; + }, + [worldBounds, gridWidth, gridHeight], + ); + + /** + * Translates 2D Grid coordinates (col, row) back into 3D World coordinates (X, Z) + */ + const gridToWorld = useCallback( + (gridX: number, gridY: number): { x: number; z: number } => { + const { minX, maxX, minZ, maxZ } = worldBounds; + + const pctX = gridX / gridWidth; + const pctZ = gridY / gridHeight; + + const worldX = minX + pctX * (maxX - minX); + const worldZ = minZ + pctZ * (maxZ - minZ); + + return { x: worldX, z: worldZ }; + }, + [worldBounds, gridWidth, gridHeight], + ); + + /** + * Runs the A* calculation using 3D world coordinates. + * Returns path in 3D world space. + */ + const calculateWorldPath = useCallback( + ( + startWorld: { x: number; z: number }, + endWorld: { x: number; z: number }, + ): { x: number; z: number }[] => { + if (!grid) return []; + + const startGrid = worldToGrid(startWorld.x, startWorld.z); + const endGrid = worldToGrid(endWorld.x, endWorld.z); + + const gridPath = findPath(grid, startGrid, endGrid, allowDiagonals); + + // Convert path coordinates back to 3D space + return gridPath.map((node) => gridToWorld(node.x, node.y)); + }, + [grid, worldToGrid, gridToWorld, allowDiagonals], + ); + + /** + * Updates an HTML5 `` element with the background color map, + * a path line, and the player/destination indicators. + */ + const renderGPSToCanvas = useCallback( + ( + canvas: HTMLCanvasElement, + path: { x: number; z: number }[], + playerWorldPos?: { x: number; z: number }, + destWorldPos?: { x: number; z: number }, + options: { + pathColor?: string; + pathWidth?: number; + playerColor?: string; + playerSize?: number; + destColor?: string; + destSize?: number; + } = {}, + ) => { + const ctx = canvas.getContext("2d"); + if (!ctx || !colorMapImgRef.current) return; + + const { + pathColor = "#3b82f6", // Premium blue + pathWidth = 6, + playerColor = "#ef4444", // Red dot for player + playerSize = 8, + destColor = "#10b981", // Green dot for flag + destSize = 8, + } = options; + + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + + // 1. Draw background color map + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight); + + // Helper: translate world coordinates to Canvas pixels + const worldToCanvas = (wx: number, wz: number): Position => { + const { minX, maxX, minZ, maxZ } = worldBounds; + const px = ((wx - minX) / (maxX - minX)) * canvasWidth; + const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight; + return { x: px, y: py }; + }; + + // 2. Draw A* Path Line + if (path.length > 1) { + ctx.beginPath(); + const startNode = path[0]!; + const startPt = worldToCanvas(startNode.x, startNode.z); + ctx.moveTo(startPt.x, startPt.y); + + for (let i = 1; i < path.length; i++) { + const node = path[i]!; + const pt = worldToCanvas(node.x, node.z); + ctx.lineTo(pt.x, pt.y); + } + + ctx.strokeStyle = pathColor; + ctx.lineWidth = pathWidth; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // Add a soft glow effect for premium feel + ctx.shadowBlur = 8; + ctx.shadowColor = pathColor; + ctx.stroke(); + + // Reset shadow for subsequent drawings + ctx.shadowBlur = 0; + } + + // 3. Draw Destination Indicator + if (destWorldPos) { + const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z); + ctx.beginPath(); + ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI); + ctx.fillStyle = destColor; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + + // 4. Draw Player Indicator + if (playerWorldPos) { + const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z); + ctx.beginPath(); + ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI); + ctx.fillStyle = playerColor; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + }, + [worldBounds], + ); + + return { + grid, + loading, + error, + calculateWorldPath, + renderGPSToCanvas, + worldToGrid, + gridToWorld, + }; +} diff --git a/src/pathfinding/useWaypointGPS.ts b/src/pathfinding/useWaypointGPS.ts new file mode 100644 index 0000000..201e012 --- /dev/null +++ b/src/pathfinding/useWaypointGPS.ts @@ -0,0 +1,215 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { findWaypointPath } from "./WaypointAStar"; +import type { Waypoint } from "./types"; +import type { WorldBounds } from "./useGPS"; + +export interface UseWaypointGPSOptions { + roadNetworkUrl: string; // URL/Path to roadNetwork.json + colorMapUrl: string; // URL/Path to color_map.png + worldBounds: WorldBounds; +} + +export function useWaypointGPS({ + roadNetworkUrl, + colorMapUrl, + worldBounds, +}: UseWaypointGPSOptions) { + const [waypoints, setWaypoints] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const colorMapImgRef = useRef(null); + + // Load waypoint list and background color map image + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + async function initGPS() { + try { + // 1. Fetch the road network JSON + const response = await fetch(roadNetworkUrl); + if (!response.ok) { + throw new Error(`Failed to load road network from ${roadNetworkUrl}`); + } + const data: Waypoint[] = await response.json(); + + // 2. Pre-load the color map image + const colorMapImg = new Image(); + colorMapImg.crossOrigin = "anonymous"; + await new Promise((resolve, reject) => { + colorMapImg.onload = resolve; + colorMapImg.onerror = reject; + colorMapImg.src = colorMapUrl; + }); + + if (active) { + setWaypoints(data); + colorMapImgRef.current = colorMapImg; + setLoading(false); + } + } catch (err: any) { + if (active) { + setError(err.message || "Failed to initialize Waypoint GPS"); + setLoading(false); + } + } + } + + initGPS(); + + return () => { + active = false; + }; + }, [roadNetworkUrl, colorMapUrl]); + + /** + * Calculates the shortest path between start and end world points. + */ + const calculateRoute = useCallback( + ( + startWorld: { x: number; y: number; z: number }, + endWorld: { x: number; y: number; z: number }, + ): Waypoint[] => { + if (waypoints.length === 0) return []; + return findWaypointPath(waypoints, startWorld, endWorld); + }, + [waypoints], + ); + + /** + * Renders the road network path, player position, and waypoint target onto a canvas. + */ + const renderGPSToCanvas = useCallback( + ( + canvas: HTMLCanvasElement, + path: Waypoint[], + playerWorldPos?: { x: number; y: number; z: number }, + destWorldPos?: { x: number; y: number; z: number }, + options: { + pathColor?: string; + pathWidth?: number; + playerColor?: string; + playerSize?: number; + destColor?: string; + destSize?: number; + showAllWaypoints?: boolean; // Debug mode + } = {}, + ) => { + const ctx = canvas.getContext("2d"); + if (!ctx || !colorMapImgRef.current) return; + + const { + pathColor = "#10b981", // Premium emerald green + pathWidth = 6, + playerColor = "#ff0055", // Neon pink-red for bike + playerSize = 8, + destColor = "#00ffcc", // Neon cyan for target + destSize = 8, + showAllWaypoints = false, + } = options; + + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + + // 1. Draw color map background + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight); + + // Helper: translate world coordinates (X, Z) to Canvas pixels (x, y) + const worldToCanvas = (wx: number, wz: number) => { + const { minX, maxX, minZ, maxZ } = worldBounds; + const px = ((wx - minX) / (maxX - minX)) * canvasWidth; + const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight; + return { x: px, y: py }; + }; + + // 2. [Debug] Draw all network connections + if (showAllWaypoints && waypoints.length > 0) { + ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"; + ctx.lineWidth = 1.5; + const drawn = new Set(); + + waypoints.forEach((wp) => { + const startPt = worldToCanvas(wp.x, wp.z); + wp.connections.forEach((connId) => { + const other = waypoints.find((w) => w.id === connId); + if (other) { + const key = + wp.id < other.id + ? `${wp.id}-${other.id}` + : `${other.id}-${wp.id}`; + if (!drawn.has(key)) { + drawn.add(key); + const endPt = worldToCanvas(other.x, other.z); + ctx.beginPath(); + ctx.moveTo(startPt.x, startPt.y); + ctx.lineTo(endPt.x, endPt.y); + ctx.stroke(); + } + } + }); + }); + } + + // 3. Draw calculated A* path line + if (path.length > 1) { + ctx.beginPath(); + const startNode = path[0]!; + const startPt = worldToCanvas(startNode.x, startNode.z); + ctx.moveTo(startPt.x, startPt.y); + + for (let i = 1; i < path.length; i++) { + const node = path[i]!; + const pt = worldToCanvas(node.x, node.z); + ctx.lineTo(pt.x, pt.y); + } + + ctx.strokeStyle = pathColor; + ctx.lineWidth = pathWidth; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // Add soft premium path glow + ctx.shadowBlur = 8; + ctx.shadowColor = pathColor; + ctx.stroke(); + ctx.shadowBlur = 0; // Reset + } + + // 4. Draw Destination target + if (destWorldPos) { + const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z); + ctx.beginPath(); + ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI); + ctx.fillStyle = destColor; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + + // 5. Draw Player / Bike + if (playerWorldPos) { + const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z); + ctx.beginPath(); + ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI); + ctx.fillStyle = playerColor; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.fill(); + ctx.stroke(); + } + }, + [worldBounds, waypoints], + ); + + return { + waypoints, + loading, + error, + calculateRoute, + renderGPSToCanvas, + }; +} diff --git a/src/router.tsx b/src/router.tsx index c836b77..137a1a8 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,6 +6,8 @@ import { } from "@tanstack/react-router"; import { HomePage } from "@/pages/page"; import { EditorPage } from "@/pages/editor/page"; +import { WaypointEditorPage } from "@/pages/waypoint/page"; +import { BackgroundMapPage } from "@/pages/backgroundmap/page"; import { DocsAnimationRoute, DocsAudioRoute, @@ -44,6 +46,18 @@ const editorRoute = createRoute({ component: EditorPage, }); +const waypointRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/waypoint", + component: WaypointEditorPage, +}); + +const backgroundMapRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/backgroundmap", + component: BackgroundMapPage, +}); + const docsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/docs", @@ -80,6 +94,8 @@ const docsChildRoutes = [ const routeTree = rootRoute.addChildren([ indexRoute, editorRoute, + waypointRoute, + backgroundMapRoute, docsRoute.addChildren(docsChildRoutes), ]); diff --git a/src/shaders/NetShader.ts b/src/shaders/NetShader.ts new file mode 100644 index 0000000..ea8af30 --- /dev/null +++ b/src/shaders/NetShader.ts @@ -0,0 +1,66 @@ +import { Mesh, PlaneGeometry, ShaderMaterial } from "three"; + +export const createNetShader = (): ShaderMaterial => { + return new ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uGridScale: { value: 15.0 }, + uPincushionStrength: { value: 0.4 }, + uBloomIntensity: { value: 0.3 }, + uGridThickness: { value: 0.02 }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uTime; + uniform float uGridScale; + uniform float uPincushionStrength; + uniform float uBloomIntensity; + uniform float uGridThickness; + + varying vec2 vUv; + + vec2 applyPincushion(vec2 uv, float strength) { + vec2 center = uv - 0.5; + float dist = length(center); + float distortion = 1.0 + dist * dist * strength; + return center * distortion + 0.5; + } + + float grid(vec2 uv, float scale, float thickness) { + vec2 gridUV = fract(uv * scale); + float lineX = smoothstep(thickness, thickness + 0.01, gridUV.x) + * smoothstep(1.0 - thickness, 1.0 - thickness - 0.01, gridUV.x); + float lineY = smoothstep(thickness, thickness + 0.01, gridUV.y) + * smoothstep(1.0 - thickness, 1.0 - thickness - 0.01, gridUV.y); + return lineX + lineY; + } + + void main() { + vec2 uv = applyPincushion(vUv, uPincushionStrength); + + float gridPattern = grid(uv, uGridScale, uGridThickness); + + vec3 gridColor = vec3(1.0, 0.4, 0.7); + vec3 bgColor = vec3(0.05, 0.02, 0.05); + + float bloom = gridPattern * uBloomIntensity; + vec3 col = mix(bgColor, gridColor + bloom, gridPattern); + + gl_FragColor = vec4(col, 1.0); + } + `, + }); +}; + +export const createNetMesh = (): Mesh => { + const geometry = new PlaneGeometry(2, 2); + const material = createNetShader(); + return new Mesh(geometry, material); +}; diff --git a/src/shaders/UnicolorShader.ts b/src/shaders/UnicolorShader.ts new file mode 100644 index 0000000..f9ef615 --- /dev/null +++ b/src/shaders/UnicolorShader.ts @@ -0,0 +1,34 @@ +import { ShaderMaterial, Color } from "three"; + +export const createUnicolorShader = ( + color: Color | string | number, +): ShaderMaterial => { + return new ShaderMaterial({ + uniforms: { + uColor: { value: color instanceof Color ? color : new Color(color) }, + }, + vertexShader: ` + varying vec3 vNormal; + varying vec3 vPosition; + + void main() { + vNormal = normalize(normalMatrix * normal); + vPosition = (modelViewMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform vec3 uColor; + varying vec3 vNormal; + varying vec3 vPosition; + + void main() { + vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0)); + float diffuse = max(dot(vNormal, lightDir), 0.0); + float ambient = 0.3; + vec3 finalColor = uColor * (ambient + diffuse * 0.7); + gl_FragColor = vec4(finalColor, 1.0); + } + `, + }); +}; diff --git a/src/world/GameCinematics.tsx b/src/world/GameCinematics.tsx index 7f60ca7..9285726 100644 --- a/src/world/GameCinematics.tsx +++ b/src/world/GameCinematics.tsx @@ -9,6 +9,7 @@ import type { CinematicManifest, } from "@/types/cinematics/cinematics"; import type { DialogueManifest } from "@/types/dialogues/dialogues"; +import type { Vector3Tuple } from "@/types/three/three"; import { logger } from "@/utils/core/Logger"; import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; @@ -16,6 +17,11 @@ import { queueDialogueById } from "@/utils/dialogues/playDialogue"; export function GameCinematics(): null { const camera = useThree((state) => state.camera); + + useEffect(() => { + setGlobalCamera(camera); + }, [camera]); + const [manifest, setManifest] = useState(null); const [dialogueManifest, setDialogueManifest] = useState(null); @@ -171,3 +177,120 @@ function playCinematic( timelineRef.current = timeline; } + +let cameraTransitionTimeline: gsap.core.Timeline | null = null; +let globalCamera: THREE.Camera | null = null; + +export function setGlobalCamera(camera: THREE.Camera | null): void { + globalCamera = camera; +} + +export function animateCameraTransition( + targetPosition: Vector3Tuple, + targetLookAt: Vector3Tuple, + duration: number = 1, + onComplete?: () => void, +): void { + if (!globalCamera) { + logger.warn("GameCinematics", "Camera not found for transition"); + onComplete?.(); + return; + } + + const camera = globalCamera; + + cameraTransitionTimeline?.kill(); + useGameStore.getState().setCinematicPlaying(true); + + const target = new THREE.Vector3(...targetLookAt); + + cameraTransitionTimeline = gsap.timeline({ + onUpdate: () => camera.lookAt(target), + onComplete: () => { + cameraTransitionTimeline = null; + useGameStore.getState().setCinematicPlaying(false); + onComplete?.(); + }, + }); + + cameraTransitionTimeline.to(camera.position, { + x: targetPosition[0], + y: targetPosition[1], + z: targetPosition[2], + duration, + ease: "power2.inOut", + }); + + cameraTransitionTimeline.to( + target, + { + x: targetLookAt[0], + y: targetLookAt[1], + z: targetLookAt[2], + duration, + ease: "power2.inOut", + }, + 0, + ); +} + +export function animateCameraTransformTransition( + targetPosition: Vector3Tuple, + targetRotation: Vector3Tuple, + duration: number = 1, + onComplete?: () => void, +): void { + if (!globalCamera) { + logger.warn("GameCinematics", "Camera not found for transition"); + onComplete?.(); + return; + } + + const camera = globalCamera; + + cameraTransitionTimeline?.kill(); + useGameStore.getState().setCinematicPlaying(true); + + // Convert target rotation in degrees to quaternion + const targetEuler = new THREE.Euler( + THREE.MathUtils.degToRad(targetRotation[0]), + THREE.MathUtils.degToRad(targetRotation[1]), + THREE.MathUtils.degToRad(targetRotation[2]), + "YXZ", + ); + const startQuaternion = camera.quaternion.clone(); + const endQuaternion = new THREE.Quaternion().setFromEuler(targetEuler); + + const transitionObj = { progress: 0 }; + + cameraTransitionTimeline = gsap.timeline({ + onUpdate: () => { + camera.quaternion + .copy(startQuaternion) + .slerp(endQuaternion, transitionObj.progress); + }, + onComplete: () => { + cameraTransitionTimeline = null; + useGameStore.getState().setCinematicPlaying(false); + onComplete?.(); + }, + }); + + cameraTransitionTimeline.to(camera.position, { + x: targetPosition[0], + y: targetPosition[1], + z: targetPosition[2], + duration, + ease: "power2.inOut", + }); + + cameraTransitionTimeline.to( + transitionObj, + { + progress: 1, + duration, + ease: "power2.inOut", + }, + 0, + ); +} diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index 3b6add0..de8102a 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -1,3 +1,4 @@ +import { Ebike } from "@/components/ebike/Ebike"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { @@ -80,6 +81,7 @@ export function GameStageContent(): React.JSX.Element { return ( <> {mainState === "intro" ? : null} + {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { const position = getRepairMissionPosition(mission, anchors); if (!position) return null; diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index e5408c6..ad79d89 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -1,11 +1,13 @@ import type { ReactNode } from "react"; -import { Component, useRef } from "react"; +import { Component, useRef, useState, useEffect } from "react"; import * as THREE from "three"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; +import { Line } from "@react-three/drei"; import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { TriggerObject } from "@/components/three/interaction/TriggerObject"; +import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; import { TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS, TEST_SCENE_FLOOR_POSITION, @@ -15,9 +17,9 @@ import { TEST_SCENE_GRABBABLE_METALNESS, TEST_SCENE_GRABBABLE_POSITION, TEST_SCENE_GRABBABLE_ROUGHNESS, + GAME_REPAIR_ZONES, TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS, TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS, - TEST_SCENE_REPAIR_ZONES, TEST_SCENE_TRIGGER_COLOR, TEST_SCENE_TRIGGER_METALNESS, TEST_SCENE_TRIGGER_POSITION, @@ -84,11 +86,61 @@ class ModelPreviewErrorBoundary extends Component< } } +interface Waypoint { + id: number; + x: number; + y: number; + z: number; + connections: number[]; +} + export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { const floorRef = useRef(null); + const [waypoints, setWaypoints] = useState([]); useOctreeGraphNode(floorRef, onOctreeReady); + // Load waypoints with double-safe fallback + useEffect(() => { + // 1. Try localStorage + const saved = localStorage.getItem("la-fabrik-waypoints"); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length > 0) { + console.log( + `[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`, + ); + setWaypoints(parsed); + return; + } + } catch (e) { + console.error("Failed to parse local storage waypoints", e); + } + } + + // 2. Try public/roadNetwork.json + console.log( + "[TestMap] Tentative de chargement depuis /roadNetwork.json...", + ); + fetch("/roadNetwork.json") + .then((res) => { + if (res.ok) return res.json(); + throw new Error("Impossible de charger /roadNetwork.json"); + }) + .then((data) => { + if (Array.isArray(data)) { + console.log( + `[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`, + ); + setWaypoints(data); + } + }) + .catch((err) => { + console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err); + }); + }, []); + return ( <> @@ -98,6 +150,45 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { + {/* Render Pathfinder Maps Waypoints & Routes visually */} + + {/* Render Connection Lines */} + {waypoints.flatMap((wp) => + wp.connections.map((connId) => { + const other = waypoints.find((w) => w.id === connId); + // Draw each line only once by enforcing wp.id < other.id + if (other && wp.id < other.id) { + return ( + + ); + } + return null; + }), + )} + + {/* Render Waypoint Spheres */} + {waypoints.map((wp) => ( + + + + + ))} + + - {TEST_SCENE_REPAIR_ZONES.map((zone) => ( + {GAME_REPAIR_ZONES.map((zone) => ( @@ -151,6 +242,42 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { ))} + {/* Dynamic Futuristic 3D GPS Dashboard Preview */} + + {/* Futuristic glowing screen frame (commented out to show true 3D transparency!) */} + {/* + + + + + */} + {/* Glow accent border (commented out to remove any orange transparency tint!) */} + {/* + + + + + */} + {/* GPS Map screen plane */} + + + + + state.camera); + useEffect(() => { + setGlobalCamera(camera); return () => { + setGlobalCamera(null); document.exitPointerLock(); }; - }, []); + }, [camera]); return ; } diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 13a7100..122a0bc 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -21,7 +21,6 @@ import { PLAYER_GRAVITY, PLAYER_JUMP_SPEED, PLAYER_MAX_DELTA, - PLAYER_WALK_SPEED, PLAYER_XZ_DAMPING_FACTOR, } from "@/data/player/playerConfig"; import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked"; @@ -30,6 +29,7 @@ import { InteractionManager } from "@/managers/InteractionManager"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import type { Vector3Tuple } from "@/types/three/three"; +import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike"; type Keys = { forward: boolean; @@ -136,9 +136,74 @@ export function PlayerController({ const wantsJump = useRef(false); const initializedRef = useRef(false); const canMove = useGameStore((state) => state.missionFlow.canMove); - + const currentSpeed = useGameStore((state) => state.player.currentSpeed); + const movementMode = useGameStore((state) => state.player.movementMode); + const movementModeRef = useRef(movementMode); + const prevMovementModeRef = useRef(movementMode); + const ebikeAngle = useRef(0); const capsule = useRef(createSpawnCapsule(spawnPosition)); + useEffect(() => { + movementModeRef.current = movementMode; + }, [movementMode]); + useEffect(() => { + if (movementMode === "ebike") { + const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [ + 0, 8.2, 0, + ]; + const targetRot: number = (window as any).ebikeParkedRotation || 0; + + const headY = targetPos[1] + PLAYER_EYE_HEIGHT; + const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS; + + capsule.current.start.set(targetPos[0], bottomY, targetPos[2]); + capsule.current.end.set(targetPos[0], headY, targetPos[2]); + velocity.current.set(0, 0, 0); + onFloor.current = false; + wantsJump.current = false; + + ebikeAngle.current = targetRot; + + const cameraOffset = new THREE.Vector3( + ...EBIKE_CAMERA_TRANSFORM.position, + ); + cameraOffset.applyAxisAngle(_up, targetRot); + + const camPos = new THREE.Vector3() + .copy(capsule.current.end) + .add(cameraOffset); + camera.position.copy(camPos); + + const pitchRad = THREE.MathUtils.degToRad( + EBIKE_CAMERA_TRANSFORM.rotation[0], + ); + const yawRad = + THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + + targetRot; + const rollRad = THREE.MathUtils.degToRad( + EBIKE_CAMERA_TRANSFORM.rotation[2], + ); + camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ"); + } else if ( + movementMode === "walk" && + prevMovementModeRef.current === "ebike" + ) { + const perspectiveCam = camera as THREE.PerspectiveCamera; + perspectiveCam.fov = 60; + perspectiveCam.updateProjectionMatrix(); + + const rightDir = new THREE.Vector3(); + camera.getWorldDirection(_forward); + _forward.setY(0).normalize(); + rightDir.crossVectors(_forward, _up).normalize(); + + const shift = rightDir.multiplyScalar(3); + capsule.current.translate(shift); + camera.position.copy(capsule.current.end); + } + prevMovementModeRef.current = movementMode; + }, [movementMode, camera]); + useLayoutEffect(() => { resetPlayerCapsule( capsule.current, @@ -266,6 +331,16 @@ export function PlayerController({ return; } + if (movementModeRef.current === "ebike") { + const turnSpeed = 1.8; + if (keys.current.left) { + ebikeAngle.current += turnSpeed * dt; + } + if (keys.current.right) { + ebikeAngle.current -= turnSpeed * dt; + } + } + camera.getWorldDirection(_forward); _forward.setY(0); if (_forward.lengthSq() > 0) { @@ -277,14 +352,16 @@ export function PlayerController({ if (!movementLocked) { if (keys.current.forward) _wishDir.add(_forward); if (keys.current.backward) _wishDir.sub(_forward); - if (keys.current.left) _wishDir.sub(_right); - if (keys.current.right) _wishDir.add(_right); + if (movementModeRef.current !== "ebike") { + if (keys.current.left) _wishDir.sub(_right); + if (keys.current.right) _wishDir.add(_right); + } } if (_wishDir.lengthSq() > 0) _wishDir.normalize(); const accel = onFloor.current - ? PLAYER_WALK_SPEED - : PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR; + ? currentSpeed + : currentSpeed * PLAYER_AIR_CONTROL_FACTOR; velocity.current.x += _wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER; velocity.current.z += @@ -353,7 +430,78 @@ export function PlayerController({ } } - camera.position.copy(capsule.current.end); + if (movementModeRef.current === "ebike") { + let targetSteer = 0; + if (keys.current.left) targetSteer = 1; + else if (keys.current.right) targetSteer = -1; + + const currentSteer = (window as any).ebikeSteerFactor || 0; + const steerFactor = THREE.MathUtils.lerp( + currentSteer, + targetSteer, + 8 * dt, + ); + (window as any).ebikeSteerFactor = steerFactor; + + const speed = velocity.current.length(); + const targetFov = 60 + Math.min(speed * 0.35, 9); + const perspectiveCam = camera as THREE.PerspectiveCamera; + perspectiveCam.fov = THREE.MathUtils.lerp( + perspectiveCam.fov, + targetFov, + 6 * dt, + ); + perspectiveCam.updateProjectionMatrix(); + + const cameraOffset = new THREE.Vector3( + ...EBIKE_CAMERA_TRANSFORM.position, + ); + cameraOffset.applyAxisAngle(_up, ebikeAngle.current); + + const swingX = -Math.abs(steerFactor) * 1.5; + const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0; + + const cameraSwing = new THREE.Vector3(swingX, 0, swingZ); + cameraSwing.applyAxisAngle(_up, ebikeAngle.current); + cameraOffset.add(cameraSwing); + + const targetCamPos = new THREE.Vector3() + .copy(capsule.current.end) + .add(cameraOffset); + + camera.position.lerp(targetCamPos, 12 * dt); + + const pitchRad = THREE.MathUtils.degToRad( + EBIKE_CAMERA_TRANSFORM.rotation[0], + ); + const yawRad = + THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + + ebikeAngle.current; + const rollRad = THREE.MathUtils.degToRad( + EBIKE_CAMERA_TRANSFORM.rotation[2], + ); + camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ"); + + const ebikeVisual = (window as any).ebikeVisualGroup?.current; + if (ebikeVisual) { + ebikeVisual.position.set( + capsule.current.end.x, + capsule.current.end.y - PLAYER_EYE_HEIGHT, + capsule.current.end.z, + ); + const leanAngle = steerFactor * 0.26; + ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ"); + } + } else { + camera.position.copy(capsule.current.end); + } + + (window as any).playerPos = [ + capsule.current.end.x, + capsule.current.end.y, + capsule.current.end.z, + ]; + (window as any).ebikeAngle = ebikeAngle.current; }); return null;