@plasius/hexagons 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -1
- package/README.md +2 -0
- package/dist/game.d.ts.map +1 -1
- package/dist/game.js +377 -6
- package/dist/styles/game.module.css +98 -2
- package/dist-cjs/game.d.ts.map +1 -1
- package/dist-cjs/game.js +375 -4
- package/dist-cjs/styles/game.module.css +98 -2
- package/docs/adrs/index.md +4 -0
- package/package.json +14 -11
- package/src/game.tsx +532 -27
- package/src/styles/game.module.css +98 -2
package/src/game.tsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type PointerEvent as ReactPointerEvent,
|
|
7
|
+
} from "react";
|
|
2
8
|
import { ErrorBoundary } from "@plasius/error";
|
|
3
9
|
import {
|
|
4
10
|
MacroBiomeLabel,
|
|
@@ -10,11 +16,35 @@ import {
|
|
|
10
16
|
import { xrSessionModes } from "@plasius/gpu-xr";
|
|
11
17
|
import {
|
|
12
18
|
buildHexMapTiles,
|
|
13
|
-
|
|
19
|
+
type HexMapTile,
|
|
14
20
|
} from "./worldMap.js";
|
|
15
21
|
import styles from "./styles/game.module.css";
|
|
16
22
|
|
|
17
23
|
const HEX_SIZE = 18;
|
|
24
|
+
const HEIGHT_SCALE = 26;
|
|
25
|
+
const HUMAN_EYE_HEIGHT = 1.72;
|
|
26
|
+
const WALK_SPEED = 20;
|
|
27
|
+
const FLY_SPEED = 32;
|
|
28
|
+
const VERTICAL_FLY_SPEED = 22;
|
|
29
|
+
const LOOK_SENSITIVITY = 0.0032;
|
|
30
|
+
const NEAR_PLANE = 0.5;
|
|
31
|
+
const FAR_PLANE = 1100;
|
|
32
|
+
const FOV_RAD = Math.PI / 3;
|
|
33
|
+
const CAMERA_UPDATE_MS = 100;
|
|
34
|
+
|
|
35
|
+
type ExplorerMode = "walk" | "fly";
|
|
36
|
+
|
|
37
|
+
type CameraPose = {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
z: number;
|
|
41
|
+
yaw: number;
|
|
42
|
+
pitch: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type CameraHud = CameraPose & {
|
|
46
|
+
ground: number;
|
|
47
|
+
};
|
|
18
48
|
|
|
19
49
|
function formatPercent(value: number): string {
|
|
20
50
|
return `${Math.round(value * 100)}%`;
|
|
@@ -24,9 +54,262 @@ function formatNumber(value: number): string {
|
|
|
24
54
|
return Number.isFinite(value) ? value.toFixed(2) : "n/a";
|
|
25
55
|
}
|
|
26
56
|
|
|
57
|
+
function clamp(value: number, min: number, max: number): number {
|
|
58
|
+
return Math.min(max, Math.max(min, value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function wrapAngle(value: number): number {
|
|
62
|
+
const full = Math.PI * 2;
|
|
63
|
+
const wrapped = ((value % full) + full) % full;
|
|
64
|
+
return wrapped > Math.PI ? wrapped - full : wrapped;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hexCorners(centerX: number, centerZ: number, size: number): Array<{ x: number; z: number }> {
|
|
68
|
+
const points: Array<{ x: number; z: number }> = [];
|
|
69
|
+
for (let i = 0; i < 6; i += 1) {
|
|
70
|
+
const angle = (Math.PI / 180) * (60 * i + 30);
|
|
71
|
+
points.push({
|
|
72
|
+
x: centerX + size * Math.cos(angle),
|
|
73
|
+
z: centerZ + size * Math.sin(angle),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return points;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tintHex(hex: string, amount: number): string {
|
|
80
|
+
const safe = hex.replace("#", "");
|
|
81
|
+
const channels = safe.length === 3
|
|
82
|
+
? safe.split("").map((c) => parseInt(c + c, 16))
|
|
83
|
+
: [0, 2, 4].map((offset) => parseInt(safe.slice(offset, offset + 2), 16));
|
|
84
|
+
const factor = clamp(1 + amount, 0.2, 1.9);
|
|
85
|
+
const next = channels
|
|
86
|
+
.map((channel) => clamp(Math.round(channel * factor), 0, 255))
|
|
87
|
+
.map((channel) => channel.toString(16).padStart(2, "0"))
|
|
88
|
+
.join("");
|
|
89
|
+
return `#${next}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findNearestTileIndex(tiles: HexMapTile[], x: number, z: number): number {
|
|
93
|
+
if (tiles.length === 0) {
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
let nearestIndex = 0;
|
|
97
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
98
|
+
for (let index = 0; index < tiles.length; index += 1) {
|
|
99
|
+
const tile = tiles[index];
|
|
100
|
+
const dx = tile.x - x;
|
|
101
|
+
const dz = tile.y - z;
|
|
102
|
+
const distance = dx * dx + dz * dz;
|
|
103
|
+
if (distance < nearestDistance) {
|
|
104
|
+
nearestDistance = distance;
|
|
105
|
+
nearestIndex = index;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return nearestIndex;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sampleGroundHeight(tiles: HexMapTile[], x: number, z: number): number {
|
|
112
|
+
const index = findNearestTileIndex(tiles, x, z);
|
|
113
|
+
const tile = tiles[index];
|
|
114
|
+
if (!tile) {
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
return tile.terrain.height * HEIGHT_SCALE;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function initialCamera(tiles: HexMapTile[]): CameraPose {
|
|
121
|
+
if (tiles.length === 0) {
|
|
122
|
+
return {
|
|
123
|
+
x: 0,
|
|
124
|
+
y: HUMAN_EYE_HEIGHT,
|
|
125
|
+
z: 0,
|
|
126
|
+
yaw: 0,
|
|
127
|
+
pitch: -0.35,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
let sumX = 0;
|
|
131
|
+
let sumZ = 0;
|
|
132
|
+
for (const tile of tiles) {
|
|
133
|
+
sumX += tile.x;
|
|
134
|
+
sumZ += tile.y;
|
|
135
|
+
}
|
|
136
|
+
const x = sumX / tiles.length;
|
|
137
|
+
const z = sumZ / tiles.length;
|
|
138
|
+
const ground = sampleGroundHeight(tiles, x, z);
|
|
139
|
+
return {
|
|
140
|
+
x,
|
|
141
|
+
y: ground + HUMAN_EYE_HEIGHT,
|
|
142
|
+
z,
|
|
143
|
+
yaw: -Math.PI * 0.12,
|
|
144
|
+
pitch: -0.38,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type ProjectedPoint = {
|
|
149
|
+
x: number;
|
|
150
|
+
y: number;
|
|
151
|
+
depth: number;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function projectPoint(
|
|
155
|
+
point: { x: number; y: number; z: number },
|
|
156
|
+
camera: CameraPose,
|
|
157
|
+
viewportWidth: number,
|
|
158
|
+
viewportHeight: number,
|
|
159
|
+
focalLength: number
|
|
160
|
+
): ProjectedPoint | null {
|
|
161
|
+
const dx = point.x - camera.x;
|
|
162
|
+
const dy = point.y - camera.y;
|
|
163
|
+
const dz = point.z - camera.z;
|
|
164
|
+
|
|
165
|
+
const sinYaw = Math.sin(camera.yaw);
|
|
166
|
+
const cosYaw = Math.cos(camera.yaw);
|
|
167
|
+
const xYaw = cosYaw * dx - sinYaw * dz;
|
|
168
|
+
const zYaw = sinYaw * dx + cosYaw * dz;
|
|
169
|
+
|
|
170
|
+
const sinPitch = Math.sin(camera.pitch);
|
|
171
|
+
const cosPitch = Math.cos(camera.pitch);
|
|
172
|
+
const yPitch = cosPitch * dy - sinPitch * zYaw;
|
|
173
|
+
const zPitch = sinPitch * dy + cosPitch * zYaw;
|
|
174
|
+
|
|
175
|
+
if (zPitch <= NEAR_PLANE || zPitch >= FAR_PLANE) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
x: viewportWidth * 0.5 + (xYaw / zPitch) * focalLength,
|
|
181
|
+
y: viewportHeight * 0.57 - (yPitch / zPitch) * focalLength,
|
|
182
|
+
depth: zPitch,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderScene(
|
|
187
|
+
context: CanvasRenderingContext2D,
|
|
188
|
+
canvas: HTMLCanvasElement,
|
|
189
|
+
tiles: HexMapTile[],
|
|
190
|
+
camera: CameraPose,
|
|
191
|
+
selectedIndex: number
|
|
192
|
+
): void {
|
|
193
|
+
const width = canvas.width;
|
|
194
|
+
const height = canvas.height;
|
|
195
|
+
|
|
196
|
+
const sky = context.createLinearGradient(0, 0, 0, height);
|
|
197
|
+
sky.addColorStop(0, "#0e1f33");
|
|
198
|
+
sky.addColorStop(0.55, "#15304b");
|
|
199
|
+
sky.addColorStop(1, "#1f2f22");
|
|
200
|
+
context.fillStyle = sky;
|
|
201
|
+
context.fillRect(0, 0, width, height);
|
|
202
|
+
|
|
203
|
+
const focalLength = (height * 0.86) / Math.tan(FOV_RAD * 0.5);
|
|
204
|
+
|
|
205
|
+
const drawQueue: Array<{
|
|
206
|
+
path: Path2D;
|
|
207
|
+
color: string;
|
|
208
|
+
depth: number;
|
|
209
|
+
highlighted: boolean;
|
|
210
|
+
}> = [];
|
|
211
|
+
|
|
212
|
+
for (let index = 0; index < tiles.length; index += 1) {
|
|
213
|
+
const tile = tiles[index];
|
|
214
|
+
const elevation = tile.terrain.height * HEIGHT_SCALE;
|
|
215
|
+
const corners = hexCorners(tile.x, tile.y, HEX_SIZE);
|
|
216
|
+
|
|
217
|
+
const projectedCorners: ProjectedPoint[] = [];
|
|
218
|
+
let depthTotal = 0;
|
|
219
|
+
let valid = true;
|
|
220
|
+
|
|
221
|
+
for (const corner of corners) {
|
|
222
|
+
const projected = projectPoint(
|
|
223
|
+
{ x: corner.x, y: elevation, z: corner.z },
|
|
224
|
+
camera,
|
|
225
|
+
width,
|
|
226
|
+
height,
|
|
227
|
+
focalLength
|
|
228
|
+
);
|
|
229
|
+
if (!projected) {
|
|
230
|
+
valid = false;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
projectedCorners.push(projected);
|
|
234
|
+
depthTotal += projected.depth;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!valid || projectedCorners.length === 0) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const path = new Path2D();
|
|
242
|
+
path.moveTo(projectedCorners[0].x, projectedCorners[0].y);
|
|
243
|
+
for (let i = 1; i < projectedCorners.length; i += 1) {
|
|
244
|
+
path.lineTo(projectedCorners[i].x, projectedCorners[i].y);
|
|
245
|
+
}
|
|
246
|
+
path.closePath();
|
|
247
|
+
|
|
248
|
+
const depth = depthTotal / projectedCorners.length;
|
|
249
|
+
const distanceShade = clamp(1 - depth / 520, 0.2, 1);
|
|
250
|
+
const elevationShade = clamp((elevation / HEIGHT_SCALE - 0.5) * 0.25, -0.16, 0.18);
|
|
251
|
+
const color = tintHex(tile.color, distanceShade - 1 + elevationShade);
|
|
252
|
+
|
|
253
|
+
drawQueue.push({
|
|
254
|
+
path,
|
|
255
|
+
color,
|
|
256
|
+
depth,
|
|
257
|
+
highlighted: index === selectedIndex,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
drawQueue.sort((a, b) => b.depth - a.depth);
|
|
262
|
+
|
|
263
|
+
for (const item of drawQueue) {
|
|
264
|
+
context.fillStyle = item.color;
|
|
265
|
+
context.fill(item.path);
|
|
266
|
+
context.lineWidth = item.highlighted ? 2.3 : 1.2;
|
|
267
|
+
context.strokeStyle = item.highlighted
|
|
268
|
+
? "rgba(255, 255, 255, 0.92)"
|
|
269
|
+
: "rgba(11, 18, 29, 0.42)";
|
|
270
|
+
context.stroke(item.path);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
context.strokeStyle = "rgba(230, 245, 255, 0.8)";
|
|
274
|
+
context.lineWidth = 1;
|
|
275
|
+
const crosshairX = width * 0.5;
|
|
276
|
+
const crosshairY = height * 0.55;
|
|
277
|
+
context.beginPath();
|
|
278
|
+
context.moveTo(crosshairX - 8, crosshairY);
|
|
279
|
+
context.lineTo(crosshairX + 8, crosshairY);
|
|
280
|
+
context.moveTo(crosshairX, crosshairY - 8);
|
|
281
|
+
context.lineTo(crosshairX, crosshairY + 8);
|
|
282
|
+
context.stroke();
|
|
283
|
+
}
|
|
284
|
+
|
|
27
285
|
export function Game() {
|
|
28
286
|
const [seed, setSeed] = useState(1337);
|
|
287
|
+
const [mode, setMode] = useState<ExplorerMode>("walk");
|
|
29
288
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
289
|
+
const [cameraHud, setCameraHud] = useState<CameraHud>({
|
|
290
|
+
x: 0,
|
|
291
|
+
y: HUMAN_EYE_HEIGHT,
|
|
292
|
+
z: 0,
|
|
293
|
+
yaw: 0,
|
|
294
|
+
pitch: -0.35,
|
|
295
|
+
ground: 0,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
299
|
+
const cameraRef = useRef<CameraPose>({
|
|
300
|
+
x: 0,
|
|
301
|
+
y: HUMAN_EYE_HEIGHT,
|
|
302
|
+
z: 0,
|
|
303
|
+
yaw: 0,
|
|
304
|
+
pitch: -0.35,
|
|
305
|
+
});
|
|
306
|
+
const keysRef = useRef<Set<string>>(new Set());
|
|
307
|
+
const dragRef = useRef({
|
|
308
|
+
active: false,
|
|
309
|
+
pointerId: -1,
|
|
310
|
+
lastX: 0,
|
|
311
|
+
lastY: 0,
|
|
312
|
+
});
|
|
30
313
|
|
|
31
314
|
const world = useMemo(
|
|
32
315
|
() => generateTemperateMixedForest({ seed, radius: 9 }),
|
|
@@ -38,18 +321,194 @@ export function Game() {
|
|
|
38
321
|
[world]
|
|
39
322
|
);
|
|
40
323
|
|
|
41
|
-
const bounds = useMemo(
|
|
42
|
-
() => computeMapBounds(tiles, HEX_SIZE),
|
|
43
|
-
[tiles]
|
|
44
|
-
);
|
|
45
|
-
|
|
46
324
|
const safeSelectedIndex =
|
|
47
325
|
selectedIndex >= 0 && selectedIndex < tiles.length ? selectedIndex : 0;
|
|
48
326
|
const selected = tiles[safeSelectedIndex];
|
|
49
327
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
const camera = initialCamera(tiles);
|
|
330
|
+
cameraRef.current = camera;
|
|
331
|
+
const nearestIndex = findNearestTileIndex(tiles, camera.x, camera.z);
|
|
332
|
+
setSelectedIndex(nearestIndex);
|
|
333
|
+
setCameraHud({
|
|
334
|
+
...camera,
|
|
335
|
+
ground: sampleGroundHeight(tiles, camera.x, camera.z),
|
|
336
|
+
});
|
|
337
|
+
}, [tiles]);
|
|
338
|
+
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
const camera = cameraRef.current;
|
|
341
|
+
const ground = sampleGroundHeight(tiles, camera.x, camera.z);
|
|
342
|
+
if (mode === "walk") {
|
|
343
|
+
camera.y = ground + HUMAN_EYE_HEIGHT;
|
|
344
|
+
} else {
|
|
345
|
+
camera.y = Math.max(camera.y, ground + HUMAN_EYE_HEIGHT + 5);
|
|
346
|
+
}
|
|
347
|
+
}, [mode, tiles]);
|
|
348
|
+
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
const canvas = canvasRef.current;
|
|
351
|
+
if (!canvas || tiles.length === 0) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const context = canvas.getContext("2d");
|
|
355
|
+
if (!context) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let frame = 0;
|
|
360
|
+
let lastTime = performance.now();
|
|
361
|
+
let hudAccumulator = 0;
|
|
362
|
+
|
|
363
|
+
const applyCanvasSize = () => {
|
|
364
|
+
const ratio = window.devicePixelRatio || 1;
|
|
365
|
+
const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));
|
|
366
|
+
const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));
|
|
367
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
368
|
+
canvas.width = width;
|
|
369
|
+
canvas.height = height;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
374
|
+
if (event.repeat) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (event.code === "KeyF") {
|
|
378
|
+
setMode((current) => (current === "walk" ? "fly" : "walk"));
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
keysRef.current.add(event.code);
|
|
383
|
+
if (
|
|
384
|
+
event.code === "Space" ||
|
|
385
|
+
event.code.startsWith("Arrow") ||
|
|
386
|
+
event.code === "KeyW" ||
|
|
387
|
+
event.code === "KeyA" ||
|
|
388
|
+
event.code === "KeyS" ||
|
|
389
|
+
event.code === "KeyD"
|
|
390
|
+
) {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
396
|
+
keysRef.current.delete(event.code);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const onBlur = () => {
|
|
400
|
+
keysRef.current.clear();
|
|
401
|
+
dragRef.current.active = false;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
window.addEventListener("keydown", onKeyDown);
|
|
405
|
+
window.addEventListener("keyup", onKeyUp);
|
|
406
|
+
window.addEventListener("blur", onBlur);
|
|
407
|
+
|
|
408
|
+
const tick = (now: number) => {
|
|
409
|
+
applyCanvasSize();
|
|
410
|
+
|
|
411
|
+
const camera = cameraRef.current;
|
|
412
|
+
const deltaSeconds = Math.min(0.05, (now - lastTime) / 1000);
|
|
413
|
+
lastTime = now;
|
|
414
|
+
|
|
415
|
+
const keys = keysRef.current;
|
|
416
|
+
const sprint = keys.has("ShiftLeft") || keys.has("ShiftRight");
|
|
417
|
+
const baseSpeed = mode === "fly" ? FLY_SPEED : WALK_SPEED;
|
|
418
|
+
const speed = sprint ? baseSpeed * 1.85 : baseSpeed;
|
|
419
|
+
|
|
420
|
+
let inputX = 0;
|
|
421
|
+
let inputZ = 0;
|
|
422
|
+
if (keys.has("KeyW") || keys.has("ArrowUp")) inputZ += 1;
|
|
423
|
+
if (keys.has("KeyS") || keys.has("ArrowDown")) inputZ -= 1;
|
|
424
|
+
if (keys.has("KeyA") || keys.has("ArrowLeft")) inputX -= 1;
|
|
425
|
+
if (keys.has("KeyD") || keys.has("ArrowRight")) inputX += 1;
|
|
426
|
+
|
|
427
|
+
const inputLength = Math.hypot(inputX, inputZ);
|
|
428
|
+
if (inputLength > 0) {
|
|
429
|
+
const nx = inputX / inputLength;
|
|
430
|
+
const nz = inputZ / inputLength;
|
|
431
|
+
const sinYaw = Math.sin(camera.yaw);
|
|
432
|
+
const cosYaw = Math.cos(camera.yaw);
|
|
433
|
+
camera.x += (nx * cosYaw + nz * sinYaw) * speed * deltaSeconds;
|
|
434
|
+
camera.z += (nz * cosYaw - nx * sinYaw) * speed * deltaSeconds;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const ground = sampleGroundHeight(tiles, camera.x, camera.z);
|
|
438
|
+
if (mode === "walk") {
|
|
439
|
+
camera.y = ground + HUMAN_EYE_HEIGHT;
|
|
440
|
+
} else {
|
|
441
|
+
let verticalInput = 0;
|
|
442
|
+
if (keys.has("Space") || keys.has("KeyE")) verticalInput += 1;
|
|
443
|
+
if (keys.has("KeyC") || keys.has("KeyQ")) verticalInput -= 1;
|
|
444
|
+
camera.y += verticalInput * VERTICAL_FLY_SPEED * deltaSeconds;
|
|
445
|
+
camera.y = Math.max(camera.y, ground + 0.6);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
camera.pitch = clamp(camera.pitch, -1.2, 0.75);
|
|
449
|
+
camera.yaw = wrapAngle(camera.yaw);
|
|
450
|
+
|
|
451
|
+
const nearestIndex = findNearestTileIndex(tiles, camera.x, camera.z);
|
|
452
|
+
renderScene(context, canvas, tiles, camera, nearestIndex);
|
|
453
|
+
|
|
454
|
+
hudAccumulator += deltaSeconds * 1000;
|
|
455
|
+
if (hudAccumulator >= CAMERA_UPDATE_MS) {
|
|
456
|
+
hudAccumulator = 0;
|
|
457
|
+
setSelectedIndex(nearestIndex);
|
|
458
|
+
setCameraHud({
|
|
459
|
+
...camera,
|
|
460
|
+
ground,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
frame = window.requestAnimationFrame(tick);
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
frame = window.requestAnimationFrame(tick);
|
|
468
|
+
|
|
469
|
+
return () => {
|
|
470
|
+
window.cancelAnimationFrame(frame);
|
|
471
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
472
|
+
window.removeEventListener("keyup", onKeyUp);
|
|
473
|
+
window.removeEventListener("blur", onBlur);
|
|
474
|
+
};
|
|
475
|
+
}, [mode, tiles]);
|
|
476
|
+
|
|
477
|
+
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
|
478
|
+
const canvas = canvasRef.current;
|
|
479
|
+
if (!canvas) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
canvas.focus();
|
|
483
|
+
canvas.setPointerCapture(event.pointerId);
|
|
484
|
+
dragRef.current.active = true;
|
|
485
|
+
dragRef.current.pointerId = event.pointerId;
|
|
486
|
+
dragRef.current.lastX = event.clientX;
|
|
487
|
+
dragRef.current.lastY = event.clientY;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
|
491
|
+
if (!dragRef.current.active || dragRef.current.pointerId !== event.pointerId) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const dx = event.clientX - dragRef.current.lastX;
|
|
495
|
+
const dy = event.clientY - dragRef.current.lastY;
|
|
496
|
+
dragRef.current.lastX = event.clientX;
|
|
497
|
+
dragRef.current.lastY = event.clientY;
|
|
498
|
+
|
|
499
|
+
const camera = cameraRef.current;
|
|
500
|
+
camera.yaw = wrapAngle(camera.yaw + dx * LOOK_SENSITIVITY);
|
|
501
|
+
camera.pitch = clamp(camera.pitch - dy * LOOK_SENSITIVITY * 0.82, -1.2, 0.75);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
|
505
|
+
if (dragRef.current.pointerId !== event.pointerId) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
dragRef.current.active = false;
|
|
509
|
+
dragRef.current.pointerId = -1;
|
|
510
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
511
|
+
};
|
|
53
512
|
|
|
54
513
|
const handleRegenerate = () => {
|
|
55
514
|
setSeed((current) => ((current * 1664525 + 1013904223) >>> 0) % 2147483647);
|
|
@@ -61,14 +520,20 @@ export function Game() {
|
|
|
61
520
|
<div className={styles.game}>
|
|
62
521
|
<header className={styles.header}>
|
|
63
522
|
<div>
|
|
64
|
-
<h1 className={styles.title}>
|
|
523
|
+
<h1 className={styles.title}>Generator</h1>
|
|
65
524
|
<p className={styles.subtitle}>
|
|
66
|
-
|
|
67
|
-
|
|
525
|
+
Explore procedurally generated terrain in first-person. Walk at human
|
|
526
|
+
eye height above the surface or switch to fly mode for aerial scouting.
|
|
68
527
|
</p>
|
|
69
528
|
</div>
|
|
70
529
|
<div className={styles.controls}>
|
|
71
530
|
<span className={styles.seed}>Seed {seed}</span>
|
|
531
|
+
<button
|
|
532
|
+
className={styles.modeButton}
|
|
533
|
+
onClick={() => setMode((current) => (current === "walk" ? "fly" : "walk"))}
|
|
534
|
+
>
|
|
535
|
+
Mode: {mode === "walk" ? "Walk" : "Fly"}
|
|
536
|
+
</button>
|
|
72
537
|
<button className={styles.button} onClick={handleRegenerate}>
|
|
73
538
|
Regenerate
|
|
74
539
|
</button>
|
|
@@ -77,20 +542,26 @@ export function Game() {
|
|
|
77
542
|
|
|
78
543
|
<div className={styles.layout}>
|
|
79
544
|
<section className={styles.mapCard}>
|
|
80
|
-
<
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
545
|
+
<canvas
|
|
546
|
+
ref={canvasRef}
|
|
547
|
+
className={styles.viewport}
|
|
548
|
+
role="img"
|
|
549
|
+
aria-label="Generator first-person world view"
|
|
550
|
+
tabIndex={0}
|
|
551
|
+
onPointerDown={handlePointerDown}
|
|
552
|
+
onPointerMove={handlePointerMove}
|
|
553
|
+
onPointerUp={handlePointerUp}
|
|
554
|
+
onPointerCancel={handlePointerUp}
|
|
555
|
+
onContextMenu={(event) => event.preventDefault()}
|
|
556
|
+
/>
|
|
557
|
+
<div className={styles.overlay}>
|
|
558
|
+
<span className={styles.overlayTitle}>
|
|
559
|
+
{mode === "walk" ? "Walking View" : "Flight View"}
|
|
560
|
+
</span>
|
|
561
|
+
<span className={styles.overlayText}>
|
|
562
|
+
Drag to look • W/A/S/D move • F toggles walk/fly
|
|
563
|
+
</span>
|
|
564
|
+
</div>
|
|
94
565
|
</section>
|
|
95
566
|
|
|
96
567
|
<aside className={styles.panel}>
|
|
@@ -152,12 +623,36 @@ export function Game() {
|
|
|
152
623
|
{selected ? formatPercent(selected.terrain.moisture) : "n/a"}
|
|
153
624
|
</dd>
|
|
154
625
|
</div>
|
|
626
|
+
<div className={styles.row}>
|
|
627
|
+
<dt className={styles.label}>Camera X/Z</dt>
|
|
628
|
+
<dd className={styles.value}>
|
|
629
|
+
{formatNumber(cameraHud.x)} / {formatNumber(cameraHud.z)}
|
|
630
|
+
</dd>
|
|
631
|
+
</div>
|
|
632
|
+
<div className={styles.row}>
|
|
633
|
+
<dt className={styles.label}>Camera Height</dt>
|
|
634
|
+
<dd className={styles.value}>{formatNumber(cameraHud.y)} m</dd>
|
|
635
|
+
</div>
|
|
636
|
+
<div className={styles.row}>
|
|
637
|
+
<dt className={styles.label}>Clearance</dt>
|
|
638
|
+
<dd className={styles.value}>
|
|
639
|
+
{formatNumber(cameraHud.y - cameraHud.ground)} m
|
|
640
|
+
</dd>
|
|
641
|
+
</div>
|
|
642
|
+
<div className={styles.row}>
|
|
643
|
+
<dt className={styles.label}>Yaw / Pitch</dt>
|
|
644
|
+
<dd className={styles.value}>
|
|
645
|
+
{Math.round((cameraHud.yaw * 180) / Math.PI)}° /{" "}
|
|
646
|
+
{Math.round((cameraHud.pitch * 180) / Math.PI)}°
|
|
647
|
+
</dd>
|
|
648
|
+
</div>
|
|
155
649
|
</dl>
|
|
156
650
|
|
|
157
651
|
<h2 className={styles.panelTitle}>GPU Stack</h2>
|
|
158
652
|
<div className={styles.chipRow}>
|
|
159
653
|
<span className={styles.chip}>worldgen: mixed-forest</span>
|
|
160
654
|
<span className={styles.chip}>tiles: {tiles.length}</span>
|
|
655
|
+
<span className={styles.chip}>mode: {mode}</span>
|
|
161
656
|
<span className={styles.chip}>
|
|
162
657
|
xr modes: {xrSessionModes.filter((mode) => mode !== "inline").length}
|
|
163
658
|
</span>
|
|
@@ -179,6 +674,16 @@ export function Game() {
|
|
|
179
674
|
</dd>
|
|
180
675
|
</div>
|
|
181
676
|
</dl>
|
|
677
|
+
|
|
678
|
+
<h2 className={styles.panelTitle}>Controls</h2>
|
|
679
|
+
<ul className={styles.controlList}>
|
|
680
|
+
<li><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</li>
|
|
681
|
+
<li><kbd>Shift</kbd> sprint</li>
|
|
682
|
+
<li><kbd>Drag</kbd> look around</li>
|
|
683
|
+
<li><kbd>F</kbd> toggle walk/fly</li>
|
|
684
|
+
<li><kbd>Space</kbd>/<kbd>E</kbd> up (fly)</li>
|
|
685
|
+
<li><kbd>Q</kbd>/<kbd>C</kbd> down (fly)</li>
|
|
686
|
+
</ul>
|
|
182
687
|
</aside>
|
|
183
688
|
</div>
|
|
184
689
|
</div>
|