@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/src/game.tsx CHANGED
@@ -1,4 +1,10 @@
1
- import { useMemo, useState } from "react";
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
- computeMapBounds,
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
- const viewBox = `${bounds.minX} ${bounds.minY} ${
51
- bounds.maxX - bounds.minX
52
- } ${bounds.maxY - bounds.minY}`;
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}>GPU World Cell Explorer</h1>
523
+ <h1 className={styles.title}>Generator</h1>
65
524
  <p className={styles.subtitle}>
66
- Hex terrain generated through <code>@plasius/gpu-world-generator</code>{" "}
67
- and configured alongside lighting/particle/XR package profiles.
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
- <svg className={styles.map} viewBox={viewBox} role="img" aria-label="Hex terrain map">
81
- {tiles.map((tile, index) => (
82
- <polygon
83
- key={`${tile.q}:${tile.r}`}
84
- className={`${styles.tile} ${
85
- index === safeSelectedIndex ? styles.tileActive : ""
86
- }`}
87
- points={tile.points}
88
- fill={tile.color}
89
- onPointerEnter={() => setSelectedIndex(index)}
90
- onClick={() => setSelectedIndex(index)}
91
- />
92
- ))}
93
- </svg>
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>