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..93d53cc --- /dev/null +++ b/public/roadNetwork.json @@ -0,0 +1,1594 @@ +[ + { + "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 + ] + } +] \ No newline at end of file diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx new file mode 100644 index 0000000..cca34e5 --- /dev/null +++ b/src/components/ebike/Ebike.tsx @@ -0,0 +1,280 @@ +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 "bike": + return { x: 8, y: 0, z: -6 }; + case "pylone": + return { x: 64, y: 0, z: -66 }; + case "ferme": + 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..05b344f --- /dev/null +++ b/src/components/ebike/EbikeGPSMap.tsx @@ -0,0 +1,469 @@ +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(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/data/player/playerConfig.ts b/src/data/player/playerConfig.ts index 360cebe..32db135 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 15489dd..20835f4 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -7,8 +7,13 @@ import { type MissionStep, type RepairMissionId, } from "@/types/gameplay/repairMission"; +import { + PLAYER_WALK_SPEED, + PLAYER_EBIKE_SPEED, +} from "@/data/player/playerConfig"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; +export type PlayerMovementMode = "walk" | "ebike"; export type { MissionStep, RepairMissionId }; interface IntroState { @@ -30,10 +35,16 @@ interface MissionFlowState { playerName: string; } +interface PlayerState { + movementMode: PlayerMovementMode; + currentSpeed: number; +} + interface GameState { mainState: MainGameState; isCinematicPlaying: boolean; missionFlow: MissionFlowState; + player: PlayerState; intro: IntroState; bike: MissionState & { isRepaired: boolean; @@ -56,6 +67,7 @@ interface GameActions { 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; @@ -209,6 +221,10 @@ function createInitialGameState(): GameState { dialogMessage: null, playerName: "", }, + player: { + movementMode: "walk", + currentSpeed: PLAYER_WALK_SPEED, + }, intro: { currentStep: "intro", dialogueAudio: null, @@ -249,6 +265,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..9be4b82 --- /dev/null +++ b/src/pages/backgroundmap/page.tsx @@ -0,0 +1,251 @@ +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..769a53a --- /dev/null +++ b/src/pages/waypoint/page.tsx @@ -0,0 +1,1085 @@ +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(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..a3ea918 --- /dev/null +++ b/src/pathfinding/AStar.ts @@ -0,0 +1,122 @@ +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; + + let 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..20c7496 --- /dev/null +++ b/src/pathfinding/GPSMinimap.tsx @@ -0,0 +1,207 @@ +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..69f2198 --- /dev/null +++ b/src/pathfinding/Grid.ts @@ -0,0 +1,100 @@ +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..b8370cf --- /dev/null +++ b/src/pathfinding/ImageToGrid.ts @@ -0,0 +1,75 @@ +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..ac46bb4 --- /dev/null +++ b/src/pathfinding/WaypointAStar.ts @@ -0,0 +1,145 @@ +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); + + let 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..3960665 --- /dev/null +++ b/src/pathfinding/index.ts @@ -0,0 +1,10 @@ +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..640b5ff --- /dev/null +++ b/src/pathfinding/types.ts @@ -0,0 +1,40 @@ +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..11be545 --- /dev/null +++ b/src/pathfinding/useGPS.ts @@ -0,0 +1,243 @@ +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..9a363d2 --- /dev/null +++ b/src/pathfinding/useWaypointGPS.ts @@ -0,0 +1,212 @@ +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 97fb9da..0bfa7e0 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, @@ -43,6 +45,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", @@ -78,6 +92,8 @@ const docsChildRoutes = [ const routeTree = rootRoute.addChildren([ indexRoute, editorRoute, + waypointRoute, + backgroundMapRoute, docsRoute.addChildren(docsChildRoutes), ]); diff --git a/src/world/GameCinematics.tsx b/src/world/GameCinematics.tsx index 7f60ca7..4ba824d 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,118 @@ 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 e18be89..359b2a4 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -1,4 +1,5 @@ import { RepairGame } from "@/components/three/gameplay/RepairGame"; +import { Ebike } from "@/components/ebike/Ebike"; import { useGameStore } from "@/managers/stores/useGameStore"; import type { RepairMissionId } from "@/types/gameplay/repairMission"; import type { Vector3Tuple } from "@/types/three/three"; @@ -56,6 +57,7 @@ export function GameStageContent(): React.JSX.Element { {mainState === "intro" ? ( ) : null} + {GAME_REPAIR_ZONES.map((zone) => ( ) : ( - )} {sceneMode !== "game" && spawnPlayer ? ( diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index 0dea786..736fdb5 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, @@ -84,11 +86,55 @@ 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 +144,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) => ( + + + + + ))} + + + {/* 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 a8f48f9..4f1ad4b 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -20,7 +20,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"; @@ -28,6 +27,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; @@ -108,6 +108,73 @@ 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); + + useEffect(() => { + movementModeRef.current = movementMode; + }, [movementMode]); + useEffect(() => { + if (movementMode === "ebike") { + // Teleport player capsule to the bike's current parked position + 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; + + // Initialize ebikeAngle to the bike's actual parked orientation! + ebikeAngle.current = targetRot; + + // Position the camera exactly at the EBIKE_CAMERA_TRANSFORM offset rotated by 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); + + // Set the camera's exact rotation according to EBIKE_CAMERA_TRANSFORM.rotation + targetRot + 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") { + // Restore default walk FOV + const perspectiveCam = camera as THREE.PerspectiveCamera; + perspectiveCam.fov = 60; + perspectiveCam.updateProjectionMatrix(); + + // Dismount! Teleport player capsule 3 units to the right + 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]); const capsule = useRef(createSpawnCapsule(spawnPosition)); @@ -220,6 +287,17 @@ export function PlayerController({ const dt = Math.min(delta, PLAYER_MAX_DELTA); + // Rotate camera on Y-axis for ebike steering + if (movementModeRef.current === "ebike") { + const turnSpeed = 1.8; // radians per second + 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) { @@ -231,14 +309,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 += @@ -282,7 +362,71 @@ export function PlayerController({ } } - camera.position.copy(capsule.current.end); + if (movementModeRef.current === "ebike") { + // Calculate dynamic steering factor + 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; + + // 1. Dynamic FOV stretch based on speed! + const speed = velocity.current.length(); + const targetFov = 60 + Math.min(speed * 0.35, 9); // stretch FOV up to 9 degrees at high speed (halved by two)! + const perspectiveCam = camera as THREE.PerspectiveCamera; + perspectiveCam.fov = THREE.MathUtils.lerp(perspectiveCam.fov, targetFov, 6 * dt); + perspectiveCam.updateProjectionMatrix(); + + // 2. Camera lag & dynamic swing trailing + const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position); + cameraOffset.applyAxisAngle(_up, ebikeAngle.current); + + // Swing camera to optimize the view for both left and right turns: + // Since the camera is on the left (X = -3.5), it naturally trails beautifully in right turns, + // but cuts forward in left turns. We compensate by pushing the camera backward (+Z) during left turns! + 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); + + // Smoothly lerp camera position to eliminate rigidity + camera.position.lerp(targetCamPos, 12 * dt); + + // 3. Dynamic camera roll based on steering! + const pitchRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[0]); + const yawRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + ebikeAngle.current; + // COMMENTED OUT: Camera roll/tilt during turns (keeping it flat) + // const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]) - steerFactor * 0.08; + const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]); + camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ"); + + // 4. Synchronize visual e-bike position and apply leaning! + 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 + ); + // Lean (roll) the bike sideways in turns (up to 15 degrees) + const leanAngle = steerFactor * 0.26; // rotate in direction of turn! + ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ"); + } + } else { + camera.position.copy(capsule.current.end); + } + + // Save player capsule end position and camera yaw globally so other components (like Ebike) can access it + (window as any).playerPos = [capsule.current.end.x, capsule.current.end.y, capsule.current.end.z]; + (window as any).ebikeAngle = ebikeAngle.current; }); return null;