terainia/web/index.html
Maximus Gorog 3a4ae970b2 pre-alpha 0.0.1 — initial multiplayer voxel sandbox
Web/wasm Rust voxel game with:
- wgpu 23 client (WebGPU when available, WebGL2 fallback)
- Chunked terrain (17x17 chunks, deterministic value-noise generator)
- Greedy meshing with frustum + distance culling
- Sky shader, leaf wind shader, distance fog
- Player physics: substepped AABB collision, gravity, fall damage,
  natural-surface respawn
- Touch UI (MCPE-style joystick + jump/break/place/sprint),
  gamepad polling with axis calibration, mouse+keyboard
- HP / death-screen / respawn flow
- 10-slot hotbar with mouse-wheel + hotkey + tap cycling
- Settings menu (mouse sens, FOV, render distance, input mode toggle)
- Axum multiplayer server: WebSocket protocol, edit log,
  10Hz player broadcasts
- 31 unit tests covering spawn invariants, collision sweeps,
  raycast hit/miss, greedy mesh winding, fall damage, oriented
  box rotation, hotbar block roundtrip, and the input-merge
  regression that latched movement after touch release

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:33:47 -06:00

632 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Voxel Game</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<style>
:root {
--ui-bg: rgba(0,0,0,0.45);
--ui-fg: #f0f0f0;
}
html, body {
margin: 0;
height: 100%;
background: #1a1a1a;
color: var(--ui-fg);
font-family: system-ui, sans-serif;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: none;
overscroll-behavior: none;
}
#game-canvas {
display: block;
width: 100vw;
height: 100dvh;
cursor: crosshair;
image-rendering: pixelated;
touch-action: none;
}
#hud {
position: fixed;
top: 12px;
left: 12px;
background: var(--ui-bg);
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
pointer-events: none;
}
#hud kbd {
background: #333;
border-radius: 3px;
padding: 1px 5px;
border: 1px solid #555;
font-size: 11px;
}
#crosshair {
position: fixed;
left: 50%; top: 50%;
width: 14px; height: 14px;
margin-left: -7px; margin-top: -7px;
pointer-events: none;
}
#crosshair::before, #crosshair::after {
content: "";
position: absolute;
background: #fff;
mix-blend-mode: difference;
}
#crosshair::before { left: 6px; top: 0; width: 2px; height: 14px; }
#crosshair::after { top: 6px; left: 0; height: 2px; width: 14px; }
/* Connection status / name */
#net-status {
position: fixed;
top: 12px; right: 12px;
background: var(--ui-bg);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
display: flex;
gap: 8px;
align-items: center;
}
#net-status .dot {
width: 9px; height: 9px; border-radius: 50%;
background: #d33; transition: background 0.3s;
}
#net-status.connected .dot { background: #6c6; }
#gp-indicator {
display: none;
font-size: 14px;
line-height: 1;
filter: grayscale(40%);
opacity: 0.8;
}
#gp-indicator.active { display: inline; filter: none; opacity: 1; }
#net-status input {
background: rgba(0,0,0,0.3);
border: 1px solid #555;
border-radius: 3px;
color: #eee;
font-size: 12px;
padding: 2px 6px;
width: 110px;
font-family: inherit;
}
/* HP bar */
#hp {
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
width: 210px;
height: 18px;
background: rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.25);
border-radius: 4px;
overflow: hidden;
}
#hp-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #e64141, #f0a040);
transition: width 0.2s;
}
#hp-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
text-shadow: 0 1px 2px #000;
}
/* Hotbar — must sit above the touch overlay (z 5) so taps land on slots
instead of being intercepted by the look pad. */
#hotbar {
position: fixed;
bottom: max(14px, env(safe-area-inset-bottom, 0));
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
background: var(--ui-bg);
padding: 5px;
border-radius: 8px;
pointer-events: auto;
z-index: 30;
touch-action: manipulation;
}
.slot {
width: 44px;
height: 44px;
border: 2px solid #555;
border-radius: 6px;
display: flex;
align-items: end;
justify-content: center;
padding-bottom: 3px;
font-size: 10px;
text-align: center;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
cursor: pointer;
user-select: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4) inset;
}
.slot.active {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255,255,255,0.30) inset, 0 0 8px rgba(255,255,255,0.35);
}
/* Narrow viewports: shrink slots so all 10 fit in one row. */
@media (max-width: 620px) {
.slot { width: 34px; height: 34px; font-size: 8px; padding-bottom: 2px; }
#hotbar { gap: 3px; padding: 4px; }
}
@media (max-width: 400px) {
.slot { width: 30px; height: 30px; font-size: 7px; padding-bottom: 2px; }
#hotbar { gap: 2px; padding: 3px; }
}
/* Menu */
#menu {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
display: none;
align-items: center;
justify-content: center;
z-index: 60;
}
body.menu-open #menu { display: flex; }
#menu-card {
background: rgba(28, 28, 32, 0.95);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 28px 32px;
width: 360px;
max-width: 92vw;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
color: #f0f0f0;
font-size: 14px;
}
#menu-card h2 {
margin: 0 0 18px;
font-size: 22px;
letter-spacing: 1px;
text-align: center;
}
.menu-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0;
gap: 12px;
}
.menu-row label { flex: 1; }
.menu-row .value { width: 56px; text-align: right; opacity: 0.85; }
.menu-row input[type="range"] {
flex: 2;
accent-color: #8aa;
}
.menu-row input[type="text"] {
flex: 2;
background: rgba(0,0,0,0.3);
border: 1px solid #444;
border-radius: 4px;
color: inherit;
padding: 4px 8px;
font-family: inherit;
font-size: 13px;
}
.seg-toggle {
flex: 2;
display: flex;
gap: 0;
border: 1px solid #444;
border-radius: 5px;
overflow: hidden;
}
.seg-toggle button {
flex: 1;
background: rgba(0,0,0,0.3);
color: #ccc;
border: none;
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.seg-toggle button + button { border-left: 1px solid #444; }
.seg-toggle button.active {
background: #6a9;
color: #001;
font-weight: 700;
}
.menu-actions {
display: flex;
gap: 10px;
margin-top: 18px;
}
.menu-actions button {
flex: 1;
padding: 10px;
background: #6a9;
color: #001;
border: none;
border-radius: 5px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.menu-actions button.secondary {
background: #444;
color: #ddd;
}
.menu-actions button:active { transform: translateY(1px); }
#menu-tip {
margin-top: 14px;
font-size: 11px;
opacity: 0.55;
text-align: center;
}
#menu-btn {
/* Must outrank #touch-overlay (z 5) — otherwise on mobile the look pad
sits on top of the button and eats the tap. */
position: fixed;
top: 12px;
right: 280px;
background: var(--ui-bg);
border: 1px solid rgba(255,255,255,0.35);
color: #fff;
width: 44px;
height: 44px;
border-radius: 6px;
font-size: 22px;
line-height: 1;
cursor: pointer;
display: none;
z-index: 30;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
#menu-btn:active { background: rgba(255,255,255,0.25); }
body.touch #menu-btn {
display: flex;
align-items: center;
justify-content: center;
right: 12px;
top: 60px;
}
/* Gamepad debug overlay — must outrank the settings menu (z 60) since
"Test gamepad…" is opened from inside the menu and we need to actually
see the overlay land on top of it. */
#gptest {
position: fixed;
right: 12px;
bottom: 80px;
z-index: 70;
width: 320px;
max-width: 92vw;
}
#gptest-card {
background: rgba(20, 20, 24, 0.94);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 8px;
padding: 12px 14px;
color: #e7e7e7;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
#gptest button {
background: #555; color: #eee; border: none;
padding: 4px 10px; border-radius: 4px;
font-family: inherit; font-size: 11px; cursor: pointer;
}
.gp-btn {
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(80,80,80,0.5);
border: 1px solid rgba(255,255,255,0.15);
display: flex; align-items: center; justify-content: center;
font-size: 9px; color: #999;
}
.gp-btn.on {
background: #6c6; color: #001; font-weight: 700;
}
.gp-stick {
width: 60px; height: 60px;
border-radius: 50%;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.25);
position: relative;
}
.gp-stick-dot {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
background: #ddd;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
transition: transform 0.05s linear;
}
.gp-stick-label {
font-size: 9px; opacity: 0.55; text-align: center; margin-top: 3px;
}
/* Death screen */
#death {
position: fixed;
inset: 0;
background: rgba(120, 0, 0, 0.55);
backdrop-filter: blur(2px);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 24px;
z-index: 100;
}
body.dead #death { display: flex; }
#death h1 {
font-size: 64px;
margin: 0;
color: #fff;
text-shadow: 0 4px 12px #000;
letter-spacing: 4px;
}
#death button {
padding: 12px 36px;
font-size: 18px;
background: #fff;
color: #200;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
#death button:active { transform: translateY(1px); }
/* Touch UI — MCPE-style: joystick left, action stack right */
body.touch #hud { display: none; }
#touch-overlay {
position: fixed;
inset: 0;
pointer-events: none; /* children opt in */
z-index: 5;
display: none;
}
body.touch #touch-overlay { display: block; }
body.touch #crosshair { display: block; }
/* Look pad sits underneath everything else, full screen, transparent.
pointer events on stick/buttons land on them first because of z-index. */
#look-pad {
position: absolute;
inset: 0;
background: transparent;
pointer-events: auto;
touch-action: none;
z-index: 1;
}
#stick {
position: absolute;
left: max(28px, env(safe-area-inset-left, 0));
bottom: max(28px, env(safe-area-inset-bottom, 0));
width: 160px; height: 160px;
border-radius: 50%;
background: rgba(0,0,0,0.22);
border: 2px solid rgba(255,255,255,0.28);
pointer-events: auto;
touch-action: none;
z-index: 2;
box-shadow: 0 4px 18px rgba(0,0,0,0.35);
}
#stick-knob {
position: absolute;
width: 64px; height: 64px;
border-radius: 50%;
background: rgba(255,255,255,0.55);
left: 50%; top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
transition: background 0.15s;
box-shadow: 0 2px 6px rgba(0,0,0,0.3) inset;
}
#stick.engaged #stick-knob { background: rgba(255,255,255,0.85); }
#touch-actions {
position: absolute;
right: max(20px, env(safe-area-inset-right, 0));
bottom: max(28px, env(safe-area-inset-bottom, 0));
display: flex;
flex-direction: column-reverse; /* jump on the bottom (thumb), break/place above */
gap: 14px;
pointer-events: none;
z-index: 2;
}
.tbtn {
width: 72px; height: 72px;
border-radius: 50%;
background: rgba(0,0,0,0.32);
border: 2px solid rgba(255,255,255,0.32);
color: #fff;
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center; justify-content: center;
pointer-events: auto;
touch-action: none;
user-select: none;
box-shadow: 0 3px 12px rgba(0,0,0,0.3);
}
.tbtn:active, .tbtn.pressed {
background: rgba(255,255,255,0.32);
transform: translateY(1px);
}
#btn-jump { width: 90px; height: 90px; font-size: 28px; }
#btn-break { background: rgba(180,40,40,0.40); }
#btn-place { background: rgba(40,140,40,0.40); }
#btn-sprint { background: rgba(80,80,140,0.40); font-size: 18px; }
/* Hide the top-right name input on touch — name lives in the menu. */
body.touch #net-status #player-name { display: none; }
</style>
</head>
<body>
<canvas id="game-canvas" width="1280" height="720" tabindex="0"></canvas>
<div id="crosshair"></div>
<div id="hud">
<strong>Voxel Game</strong><br/>
<kbd>WASD</kbd> move &nbsp; <kbd>Space</kbd> jump &nbsp; <kbd>Ctrl</kbd> sprint<br/>
Click canvas to lock mouse · <kbd>LMB</kbd> break · <kbd>RMB</kbd> place<br/>
<kbd>1</kbd><kbd>6</kbd> pick block
</div>
<div id="net-status">
<span class="dot"></span>
<span id="net-text">offline</span>
<span id="gp-indicator" title="Gamepad detected">🎮</span>
<input id="player-name" placeholder="name" maxlength="24" />
</div>
<div id="hp">
<div id="hp-fill"></div>
<div id="hp-label">20 / 20</div>
</div>
<div id="hotbar">
<div class="slot" data-b="1" style="background:#5fbb52">grass</div>
<div class="slot" data-b="2" style="background:#8c5c33">dirt</div>
<div class="slot active" data-b="3" style="background:#808284">stone</div>
<div class="slot" data-b="4" style="background:#e0d18c">sand</div>
<div class="slot" data-b="5" style="background:#6b4d2d">wood</div>
<div class="slot" data-b="6" style="background:#338b39">leaves</div>
<div class="slot" data-b="7" style="background:#6b6b73">cobble</div>
<div class="slot" data-b="8" style="background:#a64d38">brick</div>
<div class="slot" data-b="9" style="background:#f2f4f7;color:#222;text-shadow:none">snow</div>
<div class="slot" data-b="10" style="background:#9ed1f0;color:#1a3344;text-shadow:none">ice</div>
</div>
<div id="touch-overlay" aria-hidden="true">
<div id="look-pad"></div>
<div id="stick"><div id="stick-knob"></div></div>
<div id="touch-actions">
<button id="btn-jump" class="tbtn" type="button"></button>
<button id="btn-place" class="tbtn" type="button"></button>
<button id="btn-break" class="tbtn" type="button"></button>
<button id="btn-sprint" class="tbtn" type="button">»</button>
</div>
</div>
<div id="death">
<h1>YOU DIED</h1>
<button id="respawn-btn">RESPAWN</button>
</div>
<button id="menu-btn" title="Menu (Esc)"></button>
<div id="gptest" style="display:none;">
<div id="gptest-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<strong>Gamepad debug</strong>
<button id="gptest-close" type="button">close</button>
</div>
<div id="gptest-info" style="font-size:11px; opacity:0.7; margin-bottom:8px;">no controller detected — press any button or move a stick</div>
<div style="display:flex; gap:14px; align-items:center; margin-bottom:8px;">
<div>
<div class="gp-stick"><div class="gp-stick-dot" id="gp-stick-l"></div></div>
<div class="gp-stick-label" id="gp-stick-l-label">L stick</div>
</div>
<div>
<div class="gp-stick"><div class="gp-stick-dot" id="gp-stick-r"></div></div>
<div class="gp-stick-label" id="gp-stick-r-label">R stick</div>
</div>
<div style="flex:1; font-family:monospace; font-size:10px; line-height:1.4;" id="gptest-axes"></div>
</div>
<div id="gptest-buttons" style="display:flex; flex-wrap:wrap; gap:4px;"></div>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); font-size: 11px;">
<div style="opacity:0.7; margin-bottom: 5px;">
Stick calibration — push stick fully in the named direction, then click.
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<button id="gp-cal-lx" type="button">L stick ←→</button>
<button id="gp-cal-ly" type="button">L stick ↑↓</button>
<button id="gp-cal-rx" type="button">R stick ←→</button>
<button id="gp-cal-ry" type="button">R stick ↑↓</button>
</div>
<div id="gp-mapping" style="margin-top: 6px; font-family:monospace; opacity: 0.75;"></div>
<button id="gp-cal-reset" type="button" style="margin-top: 6px;">reset to defaults (0,1,2,3)</button>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
<div style="opacity:0.7; margin-bottom: 4px;">
Path verification — these bypass the gamepad and send input straight to the engine.
If clicking these moves you, the game itself is fine; the problem is upstream (Steam Input, etc).
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<button id="gp-test-fwd" type="button">▲ walk fwd 1s</button>
<button id="gp-test-jump" type="button">▼ jump once</button>
</div>
</div>
<div id="gp-kbd-state" style="margin-top: 8px; font-family:monospace; opacity:0.7;">kbd: (waiting)</div>
</div>
</div>
</div>
<div id="menu">
<div id="menu-card">
<h2>SETTINGS</h2>
<div class="menu-row">
<label>Input mode</label>
<div class="seg-toggle">
<button id="mode-pc" type="button">PC</button>
<button id="mode-mobile" type="button">Mobile</button>
</div>
</div>
<div class="menu-row">
<label for="set-name">Player name</label>
<input id="set-name" type="text" maxlength="24" />
</div>
<div class="menu-row">
<label for="set-sens">Mouse sensitivity</label>
<input id="set-sens" type="range" min="0.001" max="0.02" step="0.0005" />
<span class="value" id="set-sens-val"></span>
</div>
<div class="menu-row">
<label for="set-fov">FOV</label>
<input id="set-fov" type="range" min="40" max="110" step="1" />
<span class="value" id="set-fov-val"></span>
</div>
<div class="menu-row">
<label for="set-dist">Render distance</label>
<input id="set-dist" type="range" min="64" max="800" step="16" />
<span class="value" id="set-dist-val"></span>
</div>
<div class="menu-actions">
<button id="menu-resume">RESUME</button>
<button id="menu-respawn" class="secondary">Respawn</button>
</div>
<div class="menu-actions" style="margin-top: 8px;">
<button id="menu-gptest" class="secondary">Test gamepad…</button>
</div>
<div id="menu-tip">Esc to toggle · settings save automatically</div>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>