@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/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,34 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
- **Added**
|
|
12
|
+
- (placeholder)
|
|
13
|
+
|
|
14
|
+
- **Changed**
|
|
15
|
+
- (placeholder)
|
|
16
|
+
|
|
17
|
+
- **Fixed**
|
|
18
|
+
- (placeholder)
|
|
19
|
+
|
|
20
|
+
- **Security**
|
|
21
|
+
- (placeholder)
|
|
22
|
+
|
|
23
|
+
## [1.0.5] - 2026-02-22
|
|
24
|
+
|
|
25
|
+
- **Added**
|
|
26
|
+
- (placeholder)
|
|
27
|
+
|
|
28
|
+
- **Changed**
|
|
29
|
+
- (placeholder)
|
|
30
|
+
|
|
31
|
+
- **Fixed**
|
|
32
|
+
- (placeholder)
|
|
33
|
+
|
|
34
|
+
- **Security**
|
|
35
|
+
- (placeholder)
|
|
36
|
+
|
|
37
|
+
## [1.0.4] - 2026-02-21
|
|
38
|
+
|
|
11
39
|
- **Added**
|
|
12
40
|
- (placeholder)
|
|
13
41
|
|
|
@@ -48,7 +76,7 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
|
|
|
48
76
|
|
|
49
77
|
---
|
|
50
78
|
|
|
51
|
-
[Unreleased]: https://github.com/Plasius-LTD/hexagons/compare/v1.0.
|
|
79
|
+
[Unreleased]: https://github.com/Plasius-LTD/hexagons/compare/v1.0.5...HEAD
|
|
52
80
|
|
|
53
81
|
## [1.0.0] - 2026-02-11
|
|
54
82
|
|
|
@@ -64,3 +92,5 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
|
|
|
64
92
|
- **Security**
|
|
65
93
|
- (placeholder)
|
|
66
94
|
[1.0.3]: https://github.com/Plasius-LTD/hexagons/releases/tag/v1.0.3
|
|
95
|
+
[1.0.4]: https://github.com/Plasius-LTD/hexagons/releases/tag/v1.0.4
|
|
96
|
+
[1.0.5]: https://github.com/Plasius-LTD/hexagons/releases/tag/v1.0.5
|
package/README.md
CHANGED
package/dist/game.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"game.d.ts","sourceRoot":"","sources":["../src/game.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"game.d.ts","sourceRoot":"","sources":["../src/game.tsx"],"names":[],"mappings":"AA4RA,wBAAgB,IAAI,4CAuZnB"}
|
package/dist/game.js
CHANGED
|
@@ -1,31 +1,402 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { ErrorBoundary } from "@plasius/error";
|
|
4
4
|
import { MacroBiomeLabel, MicroFeatureLabel, SurfaceCoverLabel, TerrainBiomeLabel, generateTemperateMixedForest, } from "@plasius/gpu-world-generator";
|
|
5
5
|
import { xrSessionModes } from "@plasius/gpu-xr";
|
|
6
|
-
import { buildHexMapTiles,
|
|
6
|
+
import { buildHexMapTiles, } from "./worldMap.js";
|
|
7
7
|
import styles from "./styles/game.module.css";
|
|
8
8
|
const HEX_SIZE = 18;
|
|
9
|
+
const HEIGHT_SCALE = 26;
|
|
10
|
+
const HUMAN_EYE_HEIGHT = 1.72;
|
|
11
|
+
const WALK_SPEED = 20;
|
|
12
|
+
const FLY_SPEED = 32;
|
|
13
|
+
const VERTICAL_FLY_SPEED = 22;
|
|
14
|
+
const LOOK_SENSITIVITY = 0.0032;
|
|
15
|
+
const NEAR_PLANE = 0.5;
|
|
16
|
+
const FAR_PLANE = 1100;
|
|
17
|
+
const FOV_RAD = Math.PI / 3;
|
|
18
|
+
const CAMERA_UPDATE_MS = 100;
|
|
9
19
|
function formatPercent(value) {
|
|
10
20
|
return `${Math.round(value * 100)}%`;
|
|
11
21
|
}
|
|
12
22
|
function formatNumber(value) {
|
|
13
23
|
return Number.isFinite(value) ? value.toFixed(2) : "n/a";
|
|
14
24
|
}
|
|
25
|
+
function clamp(value, min, max) {
|
|
26
|
+
return Math.min(max, Math.max(min, value));
|
|
27
|
+
}
|
|
28
|
+
function wrapAngle(value) {
|
|
29
|
+
const full = Math.PI * 2;
|
|
30
|
+
const wrapped = ((value % full) + full) % full;
|
|
31
|
+
return wrapped > Math.PI ? wrapped - full : wrapped;
|
|
32
|
+
}
|
|
33
|
+
function hexCorners(centerX, centerZ, size) {
|
|
34
|
+
const points = [];
|
|
35
|
+
for (let i = 0; i < 6; i += 1) {
|
|
36
|
+
const angle = (Math.PI / 180) * (60 * i + 30);
|
|
37
|
+
points.push({
|
|
38
|
+
x: centerX + size * Math.cos(angle),
|
|
39
|
+
z: centerZ + size * Math.sin(angle),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return points;
|
|
43
|
+
}
|
|
44
|
+
function tintHex(hex, amount) {
|
|
45
|
+
const safe = hex.replace("#", "");
|
|
46
|
+
const channels = safe.length === 3
|
|
47
|
+
? safe.split("").map((c) => parseInt(c + c, 16))
|
|
48
|
+
: [0, 2, 4].map((offset) => parseInt(safe.slice(offset, offset + 2), 16));
|
|
49
|
+
const factor = clamp(1 + amount, 0.2, 1.9);
|
|
50
|
+
const next = channels
|
|
51
|
+
.map((channel) => clamp(Math.round(channel * factor), 0, 255))
|
|
52
|
+
.map((channel) => channel.toString(16).padStart(2, "0"))
|
|
53
|
+
.join("");
|
|
54
|
+
return `#${next}`;
|
|
55
|
+
}
|
|
56
|
+
function findNearestTileIndex(tiles, x, z) {
|
|
57
|
+
if (tiles.length === 0) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
let nearestIndex = 0;
|
|
61
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
62
|
+
for (let index = 0; index < tiles.length; index += 1) {
|
|
63
|
+
const tile = tiles[index];
|
|
64
|
+
const dx = tile.x - x;
|
|
65
|
+
const dz = tile.y - z;
|
|
66
|
+
const distance = dx * dx + dz * dz;
|
|
67
|
+
if (distance < nearestDistance) {
|
|
68
|
+
nearestDistance = distance;
|
|
69
|
+
nearestIndex = index;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return nearestIndex;
|
|
73
|
+
}
|
|
74
|
+
function sampleGroundHeight(tiles, x, z) {
|
|
75
|
+
const index = findNearestTileIndex(tiles, x, z);
|
|
76
|
+
const tile = tiles[index];
|
|
77
|
+
if (!tile) {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
return tile.terrain.height * HEIGHT_SCALE;
|
|
81
|
+
}
|
|
82
|
+
function initialCamera(tiles) {
|
|
83
|
+
if (tiles.length === 0) {
|
|
84
|
+
return {
|
|
85
|
+
x: 0,
|
|
86
|
+
y: HUMAN_EYE_HEIGHT,
|
|
87
|
+
z: 0,
|
|
88
|
+
yaw: 0,
|
|
89
|
+
pitch: -0.35,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
let sumX = 0;
|
|
93
|
+
let sumZ = 0;
|
|
94
|
+
for (const tile of tiles) {
|
|
95
|
+
sumX += tile.x;
|
|
96
|
+
sumZ += tile.y;
|
|
97
|
+
}
|
|
98
|
+
const x = sumX / tiles.length;
|
|
99
|
+
const z = sumZ / tiles.length;
|
|
100
|
+
const ground = sampleGroundHeight(tiles, x, z);
|
|
101
|
+
return {
|
|
102
|
+
x,
|
|
103
|
+
y: ground + HUMAN_EYE_HEIGHT,
|
|
104
|
+
z,
|
|
105
|
+
yaw: -Math.PI * 0.12,
|
|
106
|
+
pitch: -0.38,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function projectPoint(point, camera, viewportWidth, viewportHeight, focalLength) {
|
|
110
|
+
const dx = point.x - camera.x;
|
|
111
|
+
const dy = point.y - camera.y;
|
|
112
|
+
const dz = point.z - camera.z;
|
|
113
|
+
const sinYaw = Math.sin(camera.yaw);
|
|
114
|
+
const cosYaw = Math.cos(camera.yaw);
|
|
115
|
+
const xYaw = cosYaw * dx - sinYaw * dz;
|
|
116
|
+
const zYaw = sinYaw * dx + cosYaw * dz;
|
|
117
|
+
const sinPitch = Math.sin(camera.pitch);
|
|
118
|
+
const cosPitch = Math.cos(camera.pitch);
|
|
119
|
+
const yPitch = cosPitch * dy - sinPitch * zYaw;
|
|
120
|
+
const zPitch = sinPitch * dy + cosPitch * zYaw;
|
|
121
|
+
if (zPitch <= NEAR_PLANE || zPitch >= FAR_PLANE) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
x: viewportWidth * 0.5 + (xYaw / zPitch) * focalLength,
|
|
126
|
+
y: viewportHeight * 0.57 - (yPitch / zPitch) * focalLength,
|
|
127
|
+
depth: zPitch,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function renderScene(context, canvas, tiles, camera, selectedIndex) {
|
|
131
|
+
const width = canvas.width;
|
|
132
|
+
const height = canvas.height;
|
|
133
|
+
const sky = context.createLinearGradient(0, 0, 0, height);
|
|
134
|
+
sky.addColorStop(0, "#0e1f33");
|
|
135
|
+
sky.addColorStop(0.55, "#15304b");
|
|
136
|
+
sky.addColorStop(1, "#1f2f22");
|
|
137
|
+
context.fillStyle = sky;
|
|
138
|
+
context.fillRect(0, 0, width, height);
|
|
139
|
+
const focalLength = (height * 0.86) / Math.tan(FOV_RAD * 0.5);
|
|
140
|
+
const drawQueue = [];
|
|
141
|
+
for (let index = 0; index < tiles.length; index += 1) {
|
|
142
|
+
const tile = tiles[index];
|
|
143
|
+
const elevation = tile.terrain.height * HEIGHT_SCALE;
|
|
144
|
+
const corners = hexCorners(tile.x, tile.y, HEX_SIZE);
|
|
145
|
+
const projectedCorners = [];
|
|
146
|
+
let depthTotal = 0;
|
|
147
|
+
let valid = true;
|
|
148
|
+
for (const corner of corners) {
|
|
149
|
+
const projected = projectPoint({ x: corner.x, y: elevation, z: corner.z }, camera, width, height, focalLength);
|
|
150
|
+
if (!projected) {
|
|
151
|
+
valid = false;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
projectedCorners.push(projected);
|
|
155
|
+
depthTotal += projected.depth;
|
|
156
|
+
}
|
|
157
|
+
if (!valid || projectedCorners.length === 0) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const path = new Path2D();
|
|
161
|
+
path.moveTo(projectedCorners[0].x, projectedCorners[0].y);
|
|
162
|
+
for (let i = 1; i < projectedCorners.length; i += 1) {
|
|
163
|
+
path.lineTo(projectedCorners[i].x, projectedCorners[i].y);
|
|
164
|
+
}
|
|
165
|
+
path.closePath();
|
|
166
|
+
const depth = depthTotal / projectedCorners.length;
|
|
167
|
+
const distanceShade = clamp(1 - depth / 520, 0.2, 1);
|
|
168
|
+
const elevationShade = clamp((elevation / HEIGHT_SCALE - 0.5) * 0.25, -0.16, 0.18);
|
|
169
|
+
const color = tintHex(tile.color, distanceShade - 1 + elevationShade);
|
|
170
|
+
drawQueue.push({
|
|
171
|
+
path,
|
|
172
|
+
color,
|
|
173
|
+
depth,
|
|
174
|
+
highlighted: index === selectedIndex,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
drawQueue.sort((a, b) => b.depth - a.depth);
|
|
178
|
+
for (const item of drawQueue) {
|
|
179
|
+
context.fillStyle = item.color;
|
|
180
|
+
context.fill(item.path);
|
|
181
|
+
context.lineWidth = item.highlighted ? 2.3 : 1.2;
|
|
182
|
+
context.strokeStyle = item.highlighted
|
|
183
|
+
? "rgba(255, 255, 255, 0.92)"
|
|
184
|
+
: "rgba(11, 18, 29, 0.42)";
|
|
185
|
+
context.stroke(item.path);
|
|
186
|
+
}
|
|
187
|
+
context.strokeStyle = "rgba(230, 245, 255, 0.8)";
|
|
188
|
+
context.lineWidth = 1;
|
|
189
|
+
const crosshairX = width * 0.5;
|
|
190
|
+
const crosshairY = height * 0.55;
|
|
191
|
+
context.beginPath();
|
|
192
|
+
context.moveTo(crosshairX - 8, crosshairY);
|
|
193
|
+
context.lineTo(crosshairX + 8, crosshairY);
|
|
194
|
+
context.moveTo(crosshairX, crosshairY - 8);
|
|
195
|
+
context.lineTo(crosshairX, crosshairY + 8);
|
|
196
|
+
context.stroke();
|
|
197
|
+
}
|
|
15
198
|
export function Game() {
|
|
16
199
|
const [seed, setSeed] = useState(1337);
|
|
200
|
+
const [mode, setMode] = useState("walk");
|
|
17
201
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
202
|
+
const [cameraHud, setCameraHud] = useState({
|
|
203
|
+
x: 0,
|
|
204
|
+
y: HUMAN_EYE_HEIGHT,
|
|
205
|
+
z: 0,
|
|
206
|
+
yaw: 0,
|
|
207
|
+
pitch: -0.35,
|
|
208
|
+
ground: 0,
|
|
209
|
+
});
|
|
210
|
+
const canvasRef = useRef(null);
|
|
211
|
+
const cameraRef = useRef({
|
|
212
|
+
x: 0,
|
|
213
|
+
y: HUMAN_EYE_HEIGHT,
|
|
214
|
+
z: 0,
|
|
215
|
+
yaw: 0,
|
|
216
|
+
pitch: -0.35,
|
|
217
|
+
});
|
|
218
|
+
const keysRef = useRef(new Set());
|
|
219
|
+
const dragRef = useRef({
|
|
220
|
+
active: false,
|
|
221
|
+
pointerId: -1,
|
|
222
|
+
lastX: 0,
|
|
223
|
+
lastY: 0,
|
|
224
|
+
});
|
|
18
225
|
const world = useMemo(() => generateTemperateMixedForest({ seed, radius: 9 }), [seed]);
|
|
19
226
|
const tiles = useMemo(() => buildHexMapTiles(world.cells, world.terrain, HEX_SIZE), [world]);
|
|
20
|
-
const bounds = useMemo(() => computeMapBounds(tiles, HEX_SIZE), [tiles]);
|
|
21
227
|
const safeSelectedIndex = selectedIndex >= 0 && selectedIndex < tiles.length ? selectedIndex : 0;
|
|
22
228
|
const selected = tiles[safeSelectedIndex];
|
|
23
|
-
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const camera = initialCamera(tiles);
|
|
231
|
+
cameraRef.current = camera;
|
|
232
|
+
const nearestIndex = findNearestTileIndex(tiles, camera.x, camera.z);
|
|
233
|
+
setSelectedIndex(nearestIndex);
|
|
234
|
+
setCameraHud({
|
|
235
|
+
...camera,
|
|
236
|
+
ground: sampleGroundHeight(tiles, camera.x, camera.z),
|
|
237
|
+
});
|
|
238
|
+
}, [tiles]);
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const camera = cameraRef.current;
|
|
241
|
+
const ground = sampleGroundHeight(tiles, camera.x, camera.z);
|
|
242
|
+
if (mode === "walk") {
|
|
243
|
+
camera.y = ground + HUMAN_EYE_HEIGHT;
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
camera.y = Math.max(camera.y, ground + HUMAN_EYE_HEIGHT + 5);
|
|
247
|
+
}
|
|
248
|
+
}, [mode, tiles]);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
const canvas = canvasRef.current;
|
|
251
|
+
if (!canvas || tiles.length === 0) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const context = canvas.getContext("2d");
|
|
255
|
+
if (!context) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let frame = 0;
|
|
259
|
+
let lastTime = performance.now();
|
|
260
|
+
let hudAccumulator = 0;
|
|
261
|
+
const applyCanvasSize = () => {
|
|
262
|
+
const ratio = window.devicePixelRatio || 1;
|
|
263
|
+
const width = Math.max(1, Math.floor(canvas.clientWidth * ratio));
|
|
264
|
+
const height = Math.max(1, Math.floor(canvas.clientHeight * ratio));
|
|
265
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
266
|
+
canvas.width = width;
|
|
267
|
+
canvas.height = height;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const onKeyDown = (event) => {
|
|
271
|
+
if (event.repeat) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (event.code === "KeyF") {
|
|
275
|
+
setMode((current) => (current === "walk" ? "fly" : "walk"));
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
keysRef.current.add(event.code);
|
|
280
|
+
if (event.code === "Space" ||
|
|
281
|
+
event.code.startsWith("Arrow") ||
|
|
282
|
+
event.code === "KeyW" ||
|
|
283
|
+
event.code === "KeyA" ||
|
|
284
|
+
event.code === "KeyS" ||
|
|
285
|
+
event.code === "KeyD") {
|
|
286
|
+
event.preventDefault();
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const onKeyUp = (event) => {
|
|
290
|
+
keysRef.current.delete(event.code);
|
|
291
|
+
};
|
|
292
|
+
const onBlur = () => {
|
|
293
|
+
keysRef.current.clear();
|
|
294
|
+
dragRef.current.active = false;
|
|
295
|
+
};
|
|
296
|
+
window.addEventListener("keydown", onKeyDown);
|
|
297
|
+
window.addEventListener("keyup", onKeyUp);
|
|
298
|
+
window.addEventListener("blur", onBlur);
|
|
299
|
+
const tick = (now) => {
|
|
300
|
+
applyCanvasSize();
|
|
301
|
+
const camera = cameraRef.current;
|
|
302
|
+
const deltaSeconds = Math.min(0.05, (now - lastTime) / 1000);
|
|
303
|
+
lastTime = now;
|
|
304
|
+
const keys = keysRef.current;
|
|
305
|
+
const sprint = keys.has("ShiftLeft") || keys.has("ShiftRight");
|
|
306
|
+
const baseSpeed = mode === "fly" ? FLY_SPEED : WALK_SPEED;
|
|
307
|
+
const speed = sprint ? baseSpeed * 1.85 : baseSpeed;
|
|
308
|
+
let inputX = 0;
|
|
309
|
+
let inputZ = 0;
|
|
310
|
+
if (keys.has("KeyW") || keys.has("ArrowUp"))
|
|
311
|
+
inputZ += 1;
|
|
312
|
+
if (keys.has("KeyS") || keys.has("ArrowDown"))
|
|
313
|
+
inputZ -= 1;
|
|
314
|
+
if (keys.has("KeyA") || keys.has("ArrowLeft"))
|
|
315
|
+
inputX -= 1;
|
|
316
|
+
if (keys.has("KeyD") || keys.has("ArrowRight"))
|
|
317
|
+
inputX += 1;
|
|
318
|
+
const inputLength = Math.hypot(inputX, inputZ);
|
|
319
|
+
if (inputLength > 0) {
|
|
320
|
+
const nx = inputX / inputLength;
|
|
321
|
+
const nz = inputZ / inputLength;
|
|
322
|
+
const sinYaw = Math.sin(camera.yaw);
|
|
323
|
+
const cosYaw = Math.cos(camera.yaw);
|
|
324
|
+
camera.x += (nx * cosYaw + nz * sinYaw) * speed * deltaSeconds;
|
|
325
|
+
camera.z += (nz * cosYaw - nx * sinYaw) * speed * deltaSeconds;
|
|
326
|
+
}
|
|
327
|
+
const ground = sampleGroundHeight(tiles, camera.x, camera.z);
|
|
328
|
+
if (mode === "walk") {
|
|
329
|
+
camera.y = ground + HUMAN_EYE_HEIGHT;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
let verticalInput = 0;
|
|
333
|
+
if (keys.has("Space") || keys.has("KeyE"))
|
|
334
|
+
verticalInput += 1;
|
|
335
|
+
if (keys.has("KeyC") || keys.has("KeyQ"))
|
|
336
|
+
verticalInput -= 1;
|
|
337
|
+
camera.y += verticalInput * VERTICAL_FLY_SPEED * deltaSeconds;
|
|
338
|
+
camera.y = Math.max(camera.y, ground + 0.6);
|
|
339
|
+
}
|
|
340
|
+
camera.pitch = clamp(camera.pitch, -1.2, 0.75);
|
|
341
|
+
camera.yaw = wrapAngle(camera.yaw);
|
|
342
|
+
const nearestIndex = findNearestTileIndex(tiles, camera.x, camera.z);
|
|
343
|
+
renderScene(context, canvas, tiles, camera, nearestIndex);
|
|
344
|
+
hudAccumulator += deltaSeconds * 1000;
|
|
345
|
+
if (hudAccumulator >= CAMERA_UPDATE_MS) {
|
|
346
|
+
hudAccumulator = 0;
|
|
347
|
+
setSelectedIndex(nearestIndex);
|
|
348
|
+
setCameraHud({
|
|
349
|
+
...camera,
|
|
350
|
+
ground,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
frame = window.requestAnimationFrame(tick);
|
|
354
|
+
};
|
|
355
|
+
frame = window.requestAnimationFrame(tick);
|
|
356
|
+
return () => {
|
|
357
|
+
window.cancelAnimationFrame(frame);
|
|
358
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
359
|
+
window.removeEventListener("keyup", onKeyUp);
|
|
360
|
+
window.removeEventListener("blur", onBlur);
|
|
361
|
+
};
|
|
362
|
+
}, [mode, tiles]);
|
|
363
|
+
const handlePointerDown = (event) => {
|
|
364
|
+
const canvas = canvasRef.current;
|
|
365
|
+
if (!canvas) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
canvas.focus();
|
|
369
|
+
canvas.setPointerCapture(event.pointerId);
|
|
370
|
+
dragRef.current.active = true;
|
|
371
|
+
dragRef.current.pointerId = event.pointerId;
|
|
372
|
+
dragRef.current.lastX = event.clientX;
|
|
373
|
+
dragRef.current.lastY = event.clientY;
|
|
374
|
+
};
|
|
375
|
+
const handlePointerMove = (event) => {
|
|
376
|
+
if (!dragRef.current.active || dragRef.current.pointerId !== event.pointerId) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const dx = event.clientX - dragRef.current.lastX;
|
|
380
|
+
const dy = event.clientY - dragRef.current.lastY;
|
|
381
|
+
dragRef.current.lastX = event.clientX;
|
|
382
|
+
dragRef.current.lastY = event.clientY;
|
|
383
|
+
const camera = cameraRef.current;
|
|
384
|
+
camera.yaw = wrapAngle(camera.yaw + dx * LOOK_SENSITIVITY);
|
|
385
|
+
camera.pitch = clamp(camera.pitch - dy * LOOK_SENSITIVITY * 0.82, -1.2, 0.75);
|
|
386
|
+
};
|
|
387
|
+
const handlePointerUp = (event) => {
|
|
388
|
+
if (dragRef.current.pointerId !== event.pointerId) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
dragRef.current.active = false;
|
|
392
|
+
dragRef.current.pointerId = -1;
|
|
393
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
394
|
+
};
|
|
24
395
|
const handleRegenerate = () => {
|
|
25
396
|
setSeed((current) => ((current * 1664525 + 1013904223) >>> 0) % 2147483647);
|
|
26
397
|
setSelectedIndex(0);
|
|
27
398
|
};
|
|
28
|
-
return (_jsx(ErrorBoundary, { name: "Game", children: _jsxs("div", { className: styles.game, children: [_jsxs("header", { className: styles.header, children: [_jsxs("div", { children: [_jsx("h1", { className: styles.title, children: "
|
|
399
|
+
return (_jsx(ErrorBoundary, { name: "Game", children: _jsxs("div", { className: styles.game, children: [_jsxs("header", { className: styles.header, children: [_jsxs("div", { children: [_jsx("h1", { className: styles.title, children: "Generator" }), _jsx("p", { className: styles.subtitle, children: "Explore procedurally generated terrain in first-person. Walk at human eye height above the surface or switch to fly mode for aerial scouting." })] }), _jsxs("div", { className: styles.controls, children: [_jsxs("span", { className: styles.seed, children: ["Seed ", seed] }), _jsxs("button", { className: styles.modeButton, onClick: () => setMode((current) => (current === "walk" ? "fly" : "walk")), children: ["Mode: ", mode === "walk" ? "Walk" : "Fly"] }), _jsx("button", { className: styles.button, onClick: handleRegenerate, children: "Regenerate" })] })] }), _jsxs("div", { className: styles.layout, children: [_jsxs("section", { className: styles.mapCard, children: [_jsx("canvas", { ref: canvasRef, className: styles.viewport, role: "img", "aria-label": "Generator first-person world view", tabIndex: 0, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { className: styles.overlay, children: [_jsx("span", { className: styles.overlayTitle, children: mode === "walk" ? "Walking View" : "Flight View" }), _jsx("span", { className: styles.overlayText, children: "Drag to look \u2022 W/A/S/D move \u2022 F toggles walk/fly" })] })] }), _jsxs("aside", { className: styles.panel, children: [_jsx("h2", { className: styles.panelTitle, children: "Selected Tile" }), _jsxs("dl", { className: styles.stats, children: [_jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Axial" }), _jsxs("dd", { className: styles.value, children: ["q ", selected?.q ?? 0, ", r ", selected?.r ?? 0] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Biome" }), _jsx("dd", { className: styles.value, children: selected
|
|
29
400
|
? TerrainBiomeLabel[selected.terrain.biome]
|
|
30
401
|
: "Unknown" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Macro Biome" }), _jsx("dd", { className: styles.value, children: selected?.terrain.macroBiome === undefined
|
|
31
402
|
? "n/a"
|
|
@@ -33,5 +404,5 @@ export function Game() {
|
|
|
33
404
|
? "n/a"
|
|
34
405
|
: SurfaceCoverLabel[selected.terrain.surface] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Feature" }), _jsx("dd", { className: styles.value, children: selected?.terrain.feature === undefined
|
|
35
406
|
? "none"
|
|
36
|
-
: MicroFeatureLabel[selected.terrain.feature] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Height" }), _jsx("dd", { className: styles.value, children: selected ? formatNumber(selected.terrain.height) : "n/a" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Heat" }), _jsx("dd", { className: styles.value, children: selected ? formatPercent(selected.terrain.heat) : "n/a" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Moisture" }), _jsx("dd", { className: styles.value, children: selected ? formatPercent(selected.terrain.moisture) : "n/a" })] })] }), _jsx("h2", { className: styles.panelTitle, children: "GPU Stack" }), _jsxs("div", { className: styles.chipRow, children: [_jsx("span", { className: styles.chip, children: "worldgen: mixed-forest" }), _jsxs("span", { className: styles.chip, children: ["tiles: ", tiles.length] }), _jsxs("span", { className: styles.chip, children: ["xr modes: ", xrSessionModes.filter((mode) => mode !== "inline").length] })] }), _jsxs("dl", { className: styles.stats, children: [_jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "World Generator" }), _jsx("dd", { className: styles.value, children: "@plasius/gpu-world-generator" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "XR Runtime" }), _jsx("dd", { className: styles.value, children: "@plasius/gpu-xr" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "XR Session Modes" }), _jsx("dd", { className: styles.value, children: xrSessionModes.filter((mode) => mode !== "inline").join(", ") })] })] })] })] })] }) }));
|
|
407
|
+
: MicroFeatureLabel[selected.terrain.feature] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Height" }), _jsx("dd", { className: styles.value, children: selected ? formatNumber(selected.terrain.height) : "n/a" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Heat" }), _jsx("dd", { className: styles.value, children: selected ? formatPercent(selected.terrain.heat) : "n/a" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Moisture" }), _jsx("dd", { className: styles.value, children: selected ? formatPercent(selected.terrain.moisture) : "n/a" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Camera X/Z" }), _jsxs("dd", { className: styles.value, children: [formatNumber(cameraHud.x), " / ", formatNumber(cameraHud.z)] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Camera Height" }), _jsxs("dd", { className: styles.value, children: [formatNumber(cameraHud.y), " m"] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Clearance" }), _jsxs("dd", { className: styles.value, children: [formatNumber(cameraHud.y - cameraHud.ground), " m"] })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "Yaw / Pitch" }), _jsxs("dd", { className: styles.value, children: [Math.round((cameraHud.yaw * 180) / Math.PI), "\u00B0 /", " ", Math.round((cameraHud.pitch * 180) / Math.PI), "\u00B0"] })] })] }), _jsx("h2", { className: styles.panelTitle, children: "GPU Stack" }), _jsxs("div", { className: styles.chipRow, children: [_jsx("span", { className: styles.chip, children: "worldgen: mixed-forest" }), _jsxs("span", { className: styles.chip, children: ["tiles: ", tiles.length] }), _jsxs("span", { className: styles.chip, children: ["mode: ", mode] }), _jsxs("span", { className: styles.chip, children: ["xr modes: ", xrSessionModes.filter((mode) => mode !== "inline").length] })] }), _jsxs("dl", { className: styles.stats, children: [_jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "World Generator" }), _jsx("dd", { className: styles.value, children: "@plasius/gpu-world-generator" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "XR Runtime" }), _jsx("dd", { className: styles.value, children: "@plasius/gpu-xr" })] }), _jsxs("div", { className: styles.row, children: [_jsx("dt", { className: styles.label, children: "XR Session Modes" }), _jsx("dd", { className: styles.value, children: xrSessionModes.filter((mode) => mode !== "inline").join(", ") })] })] }), _jsx("h2", { className: styles.panelTitle, children: "Controls" }), _jsxs("ul", { className: styles.controlList, children: [_jsxs("li", { children: [_jsx("kbd", { children: "W" }), _jsx("kbd", { children: "A" }), _jsx("kbd", { children: "S" }), _jsx("kbd", { children: "D" }), " move"] }), _jsxs("li", { children: [_jsx("kbd", { children: "Shift" }), " sprint"] }), _jsxs("li", { children: [_jsx("kbd", { children: "Drag" }), " look around"] }), _jsxs("li", { children: [_jsx("kbd", { children: "F" }), " toggle walk/fly"] }), _jsxs("li", { children: [_jsx("kbd", { children: "Space" }), "/", _jsx("kbd", { children: "E" }), " up (fly)"] }), _jsxs("li", { children: [_jsx("kbd", { children: "Q" }), "/", _jsx("kbd", { children: "C" }), " down (fly)"] })] })] })] })] }) }));
|
|
37
408
|
}
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
.controls {
|
|
46
46
|
display: flex;
|
|
47
47
|
align-items: center;
|
|
48
|
+
flex-wrap: wrap;
|
|
48
49
|
gap: 0.65rem;
|
|
50
|
+
justify-content: flex-end;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
.seed {
|
|
@@ -57,6 +59,27 @@
|
|
|
57
59
|
padding: 0.3rem 0.7rem;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
.modeButton {
|
|
63
|
+
border: 1px solid rgba(120, 201, 255, 0.44);
|
|
64
|
+
color: var(--text);
|
|
65
|
+
background: rgba(120, 201, 255, 0.16);
|
|
66
|
+
border-radius: 999px;
|
|
67
|
+
padding: 0.5rem 0.9rem;
|
|
68
|
+
font-weight: 600;
|
|
69
|
+
letter-spacing: 0.01em;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
transition: transform 120ms ease, filter 120ms ease;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.modeButton:hover {
|
|
75
|
+
transform: translateY(-1px);
|
|
76
|
+
filter: brightness(1.06);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.modeButton:active {
|
|
80
|
+
transform: translateY(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
.button {
|
|
61
84
|
border: 0;
|
|
62
85
|
color: #001323;
|
|
@@ -93,13 +116,55 @@
|
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
.mapCard {
|
|
119
|
+
position: relative;
|
|
96
120
|
padding: 0.65rem;
|
|
97
121
|
}
|
|
98
122
|
|
|
99
|
-
.
|
|
123
|
+
.viewport {
|
|
100
124
|
display: block;
|
|
101
125
|
width: 100%;
|
|
102
126
|
height: min(72vh, 760px);
|
|
127
|
+
border-radius: 0.8rem;
|
|
128
|
+
background:
|
|
129
|
+
radial-gradient(90% 85% at 50% 100%, rgba(86, 118, 70, 0.35), transparent 70%),
|
|
130
|
+
linear-gradient(180deg, #0b1c2f, #112943 56%, #1b3428);
|
|
131
|
+
cursor: grab;
|
|
132
|
+
touch-action: none;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.viewport:active {
|
|
136
|
+
cursor: grabbing;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.viewport:focus-visible {
|
|
140
|
+
outline: 2px solid rgba(138, 214, 255, 0.8);
|
|
141
|
+
outline-offset: 2px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.overlay {
|
|
145
|
+
position: absolute;
|
|
146
|
+
left: 1.2rem;
|
|
147
|
+
bottom: 1.15rem;
|
|
148
|
+
display: grid;
|
|
149
|
+
gap: 0.18rem;
|
|
150
|
+
max-width: min(88%, 32rem);
|
|
151
|
+
padding: 0.6rem 0.78rem;
|
|
152
|
+
border-radius: 0.65rem;
|
|
153
|
+
border: 1px solid rgba(185, 218, 255, 0.26);
|
|
154
|
+
background: rgba(3, 9, 17, 0.58);
|
|
155
|
+
backdrop-filter: blur(4px);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.overlayTitle {
|
|
159
|
+
font-size: 0.77rem;
|
|
160
|
+
letter-spacing: 0.06em;
|
|
161
|
+
text-transform: uppercase;
|
|
162
|
+
color: #bde4ff;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.overlayText {
|
|
166
|
+
font-size: 0.78rem;
|
|
167
|
+
color: rgba(226, 239, 255, 0.92);
|
|
103
168
|
}
|
|
104
169
|
|
|
105
170
|
.tile {
|
|
@@ -173,6 +238,31 @@
|
|
|
173
238
|
border: 1px solid rgba(120, 201, 255, 0.34);
|
|
174
239
|
}
|
|
175
240
|
|
|
241
|
+
.controlList {
|
|
242
|
+
margin: 0;
|
|
243
|
+
padding-left: 1rem;
|
|
244
|
+
display: grid;
|
|
245
|
+
gap: 0.35rem;
|
|
246
|
+
color: var(--text-muted);
|
|
247
|
+
font-size: 0.82rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.controlList li {
|
|
251
|
+
display: flex;
|
|
252
|
+
gap: 0.3rem;
|
|
253
|
+
flex-wrap: wrap;
|
|
254
|
+
align-items: center;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.controlList kbd {
|
|
258
|
+
font-size: 0.73rem;
|
|
259
|
+
border-radius: 0.32rem;
|
|
260
|
+
border: 1px solid rgba(157, 179, 209, 0.4);
|
|
261
|
+
padding: 0.12rem 0.32rem;
|
|
262
|
+
color: #e7f0ff;
|
|
263
|
+
background: rgba(14, 24, 38, 0.72);
|
|
264
|
+
}
|
|
265
|
+
|
|
176
266
|
@keyframes reveal {
|
|
177
267
|
from {
|
|
178
268
|
opacity: 0;
|
|
@@ -202,7 +292,13 @@
|
|
|
202
292
|
align-items: flex-start;
|
|
203
293
|
}
|
|
204
294
|
|
|
205
|
-
.
|
|
295
|
+
.viewport {
|
|
206
296
|
height: min(58vh, 540px);
|
|
207
297
|
}
|
|
298
|
+
|
|
299
|
+
.overlay {
|
|
300
|
+
left: 1rem;
|
|
301
|
+
right: 1rem;
|
|
302
|
+
max-width: none;
|
|
303
|
+
}
|
|
208
304
|
}
|
package/dist-cjs/game.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"game.d.ts","sourceRoot":"","sources":["../src/game.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"game.d.ts","sourceRoot":"","sources":["../src/game.tsx"],"names":[],"mappings":"AA4RA,wBAAgB,IAAI,4CAuZnB"}
|