Compare commits
3 commits
3a4ae970b2
...
e4cf5a9bed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4cf5a9bed | ||
|
|
f239a939ce | ||
|
|
b52c1927cf |
22 changed files with 1946 additions and 749 deletions
16
.dockerignore
Normal file
16
.dockerignore
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Keep build artifacts out of the build context so the image stays small.
|
||||
target/
|
||||
**/target/
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Generated wasm in web/ — we want the *build* to produce these.
|
||||
web/voxel_game.js
|
||||
web/voxel_game_bg.wasm
|
||||
web/voxel_game.d.ts
|
||||
|
||||
# Misc
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
12
Caddyfile
Normal file
12
Caddyfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Public reverse-proxy front. Mirrors the `.wg` Caddy convention but
|
||||
# uses real Let's Encrypt certs (the wg version uses `tls internal`).
|
||||
# Caddy fronts the voxel container over the Docker network, so the
|
||||
# voxel service no longer publishes a host port.
|
||||
{
|
||||
email mgorog@gmail.com
|
||||
}
|
||||
|
||||
voxel.mxvs.art {
|
||||
encode zstd gzip
|
||||
reverse_proxy voxel:8080
|
||||
}
|
||||
113
DEPLOY.md
Normal file
113
DEPLOY.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Deploying the voxel game
|
||||
|
||||
Three layers, pick the combination that fits.
|
||||
|
||||
## Local-only (development)
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Serves on `http://localhost:8080`. `127.0.0.1`-bound — not reachable from
|
||||
the network. Good for iterating on Docker without exposing anything.
|
||||
|
||||
## Anywhere with a public IP (the real deploy)
|
||||
|
||||
This assumes a cheap VPS with Docker + docker-compose-plugin installed.
|
||||
Tested targets: Hetzner CPX11 (~$5/mo), DigitalOcean Basic Droplet ($4),
|
||||
Vultr (~$2.50), Oracle Cloud Always Free tier (ARM Ampere instance — free
|
||||
forever, just slow to provision).
|
||||
|
||||
### 1. SSH in and install Docker
|
||||
|
||||
Debian/Ubuntu host:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER # log out + back in
|
||||
```
|
||||
|
||||
### 2. Get the code on the box
|
||||
|
||||
```sh
|
||||
git clone https://maxgit.wg/max/terainia.git # or wherever you push it
|
||||
cd terainia
|
||||
```
|
||||
|
||||
### 3. Build + run
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
The container binds to `0.0.0.0:8080`. The first build takes ~5–8 min on
|
||||
a small VPS because it compiles Rust + the wasm client; subsequent
|
||||
builds are fast due to layer caching.
|
||||
|
||||
### 4. Put TLS in front
|
||||
|
||||
You have two clean options here.
|
||||
|
||||
**Option A — Cloudflare proxy (no cert on the VPS, simplest).**
|
||||
|
||||
- In your Cloudflare dashboard for `mxvs.art`, add an A record:
|
||||
`voxel` → `<VPS public IP>`, proxy status **Proxied** (orange cloud).
|
||||
- Cloudflare → SSL/TLS → set encryption mode to **Flexible**
|
||||
(Cloudflare terminates HTTPS, talks HTTP to the VPS).
|
||||
- That's it. `https://voxel.mxvs.art` serves the game.
|
||||
|
||||
**Option B — Caddy on the VPS for Let's Encrypt.**
|
||||
|
||||
If you want real end-to-end TLS instead of Cloudflare-terminated:
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml addition
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
```
|
||||
|
||||
```caddy
|
||||
# Caddyfile
|
||||
voxel.mxvs.art {
|
||||
reverse_proxy voxel:8080
|
||||
}
|
||||
```
|
||||
|
||||
Then keep `voxel` bound to `127.0.0.1:8080` (or use Compose's internal
|
||||
network only — drop the `ports:` mapping on the `voxel` service and let
|
||||
Caddy reach it via the service name). The DNS A record in Cloudflare
|
||||
should be **DNS only** (grey cloud) in this case so Cloudflare doesn't
|
||||
re-terminate TLS.
|
||||
|
||||
### 5. Deploy updates
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Down-and-up time is a couple of seconds while the new container
|
||||
swaps in.
|
||||
|
||||
## Local + Cloudflare Tunnel (no VPS)
|
||||
|
||||
If you don't want a VPS yet, the game can run on your machine and be
|
||||
exposed through a Cloudflare named tunnel pointing at `voxel.mxvs.art`.
|
||||
The Dockerfile is still useful for keeping the local environment
|
||||
consistent — just `docker compose up` and then point a `cloudflared`
|
||||
container at `host.docker.internal:8080`. See the Cloudflare Tunnel
|
||||
docs for the named-tunnel setup; the credential file (`cert.pem` from
|
||||
`cloudflared tunnel login`) needs to land at `~/.cloudflared/cert.pem`
|
||||
on whichever host the tunnel runs on.
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Multi-stage build: compile the wasm client + the server in one Rust
|
||||
# image, then copy the artifacts into a tiny Debian runtime.
|
||||
FROM rust:1-bookworm AS builder
|
||||
|
||||
# Pin wasm-bindgen-cli to the same version we use during development. If
|
||||
# this number drifts from Cargo.lock the wasm load will fail.
|
||||
ARG WASM_BINDGEN_VERSION=0.2.122
|
||||
|
||||
# Install the wasm32 target and wasm-bindgen-cli.
|
||||
RUN rustup target add wasm32-unknown-unknown && \
|
||||
cargo install wasm-bindgen-cli --version ${WASM_BINDGEN_VERSION} --locked
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy the whole project. The .dockerignore keeps target/ out so we get
|
||||
# a clean release build inside the container.
|
||||
COPY . .
|
||||
|
||||
# Build the wasm client (release) and run wasm-bindgen to emit the
|
||||
# JS glue + bg.wasm into web/.
|
||||
RUN cargo build --target wasm32-unknown-unknown --release --lib && \
|
||||
wasm-bindgen --target web --out-dir web --no-typescript \
|
||||
target/wasm32-unknown-unknown/release/voxel_game.wasm
|
||||
|
||||
# Build the multiplayer server.
|
||||
RUN cd server && cargo build --release
|
||||
|
||||
# ---- Runtime image ----
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
# ca-certificates lets the server speak HTTPS if it ever needs to (it
|
||||
# doesn't yet, but it's tiny and avoids surprises). tini handles signal
|
||||
# forwarding so `docker stop` is clean.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates tini && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/server/target/release/voxel-server /usr/local/bin/voxel-server
|
||||
COPY --from=builder /build/web /app/web
|
||||
|
||||
ENV STATIC_DIR=/app/web
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Run as non-root for safety.
|
||||
RUN useradd --create-home --shell /bin/false --uid 10001 voxel && \
|
||||
chown -R voxel:voxel /app
|
||||
USER voxel
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/usr/local/bin/voxel-server"]
|
||||
27
docker-compose.prod.yml
Normal file
27
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Production overrides — Caddy on 80/443 terminates TLS and reverse
|
||||
# proxies to the voxel container over the internal Docker network.
|
||||
# The voxel service drops its host port mapping (was 127.0.0.1:8080:8080
|
||||
# in the base file); Caddy reaches it via the service name. Run with:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
services:
|
||||
voxel:
|
||||
ports: !override []
|
||||
restart: always
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: voxel-caddy
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- voxel
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
services:
|
||||
voxel:
|
||||
build: .
|
||||
image: voxel-game:latest
|
||||
container_name: voxel-game
|
||||
restart: unless-stopped
|
||||
# Bind to 127.0.0.1 by default so the container isn't accidentally
|
||||
# public when running on a workstation. Override the LHS to 0.0.0.0
|
||||
# in your deploy environment (or via docker-compose.override.yml) if
|
||||
# you want the host's external interface to serve it.
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
environment:
|
||||
# voxel-server reads these. STATIC_DIR is baked in by the Dockerfile,
|
||||
# but you can override them here if mounting a different web/ from
|
||||
# the host.
|
||||
PORT: "8080"
|
||||
RUST_LOG: "info"
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
pub mod camera;
|
||||
pub mod mesh;
|
||||
pub mod net;
|
||||
pub mod proto;
|
||||
pub mod sim;
|
||||
pub mod state;
|
||||
pub mod world;
|
||||
|
||||
|
|
|
|||
185
src/mesh.rs
185
src/mesh.rs
|
|
@ -9,6 +9,10 @@ pub struct Vertex {
|
|||
pub color: [f32; 3],
|
||||
pub normal: [f32; 3],
|
||||
pub leaf: f32,
|
||||
/// Per-vertex ambient occlusion baked at mesh-build time, 0..1
|
||||
/// (0 = fully occluded crevice, 1 = open). Computed once on the CPU so
|
||||
/// the fragment shader pays one multiply.
|
||||
pub ao: f32,
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
|
|
@ -20,6 +24,7 @@ impl Vertex {
|
|||
1 => Float32x3,
|
||||
2 => Float32x3,
|
||||
3 => Float32,
|
||||
4 => Float32,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -29,9 +34,49 @@ pub struct ChunkMesh {
|
|||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Greedy meshing: per face direction, build a 2D mask per slice and merge same-block
|
||||
/// rectangles into one quad. Dramatically reduces triangle count on large flat regions
|
||||
/// (terrain, big walls).
|
||||
/// One mask cell — block type plus the four per-corner AO levels.
|
||||
/// Cells with the same block but different AO can't be greedy-merged, since
|
||||
/// they would otherwise share corner vertices that disagree on shading.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
struct MaskCell {
|
||||
block: Block,
|
||||
/// AO at the four corners in (min-u, min-v) (max-u, min-v) (max-u, max-v) (min-u, max-v)
|
||||
/// order, each value 0..=3.
|
||||
ao: [u8; 4],
|
||||
}
|
||||
|
||||
/// Classic Minecraft 4-corner AO. `face_plane` is the *air* block one step
|
||||
/// past the face in the normal direction. `du` and `dv` are unit vectors
|
||||
/// in the face plane pointing toward the corner of interest. Returns 0..=3
|
||||
/// (0 = darkest crevice, 3 = open). Special case: if both adjacent edge
|
||||
/// blocks are solid the corner is fully dark regardless of the diagonal.
|
||||
fn corner_ao(world: &World, face_plane: IVec3, du: IVec3, dv: IVec3) -> u8 {
|
||||
let s1 = world.get_block(face_plane + du).solid() as u8;
|
||||
let s2 = world.get_block(face_plane + dv).solid() as u8;
|
||||
let c = world.get_block(face_plane + du + dv).solid() as u8;
|
||||
if s1 == 1 && s2 == 1 {
|
||||
0
|
||||
} else {
|
||||
3 - (s1 + s2 + c)
|
||||
}
|
||||
}
|
||||
|
||||
const AO_TABLE: [f32; 4] = [0.45, 0.65, 0.80, 1.00];
|
||||
|
||||
fn unit_axis(a: usize) -> IVec3 {
|
||||
match a {
|
||||
0 => IVec3::X,
|
||||
1 => IVec3::Y,
|
||||
2 => IVec3::Z,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy meshing with baked AO. For each face direction we build a 2-D
|
||||
/// mask of `(block, ao[4])` cells, then merge contiguous cells that match
|
||||
/// exactly in both block-type and AO tuple. The output mesh carries one AO
|
||||
/// value per vertex; the fragment shader multiplies it into the lit color
|
||||
/// so crevices darken naturally.
|
||||
pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
||||
let mut vertices: Vec<Vertex> = Vec::with_capacity(2048);
|
||||
let mut indices: Vec<u32> = Vec::with_capacity(3072);
|
||||
|
|
@ -55,7 +100,10 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
let size_v = dims[v_axis];
|
||||
let n_arr = [normal.x as f32, normal.y as f32, normal.z as f32];
|
||||
|
||||
let mut mask: Vec<Option<Block>> = vec![None; (size_u * size_v) as usize];
|
||||
let a_unit = unit_axis(u_axis);
|
||||
let b_unit = unit_axis(v_axis);
|
||||
|
||||
let mut mask: Vec<Option<MaskCell>> = vec![None; (size_u * size_v) as usize];
|
||||
|
||||
for d in 0..size_a {
|
||||
for cell in mask.iter_mut() {
|
||||
|
|
@ -83,9 +131,21 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
.get_block(IVec3::new(base_x + nx, ny, base_z + nz))
|
||||
.solid()
|
||||
};
|
||||
if !neighbor_solid {
|
||||
mask[(v * size_u + u) as usize] = Some(block);
|
||||
if neighbor_solid {
|
||||
continue;
|
||||
}
|
||||
// World-space block position.
|
||||
let block_world = IVec3::new(base_x + p[0], p[1], base_z + p[2]);
|
||||
let face_plane = block_world + normal;
|
||||
// 4 corner AO values, ordered (min-u min-v), (max-u min-v),
|
||||
// (max-u max-v), (min-u max-v).
|
||||
let ao = [
|
||||
corner_ao(world, face_plane, -a_unit, -b_unit),
|
||||
corner_ao(world, face_plane, a_unit, -b_unit),
|
||||
corner_ao(world, face_plane, a_unit, b_unit),
|
||||
corner_ao(world, face_plane, -a_unit, b_unit),
|
||||
];
|
||||
mask[(v * size_u + u) as usize] = Some(MaskCell { block, ao });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,17 +153,20 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
let mut u0 = 0;
|
||||
while u0 < size_u {
|
||||
let head = mask[(v0 * size_u + u0) as usize];
|
||||
if let Some(b) = head {
|
||||
if let Some(cell) = head {
|
||||
// Greedy extend in u as long as cells match exactly.
|
||||
let mut w = 1;
|
||||
while u0 + w < size_u
|
||||
&& mask[(v0 * size_u + u0 + w) as usize] == Some(b)
|
||||
&& mask[(v0 * size_u + u0 + w) as usize] == Some(cell)
|
||||
{
|
||||
w += 1;
|
||||
}
|
||||
// Greedy extend in v as long as every cell in the
|
||||
// candidate row matches the head's (block, ao).
|
||||
let mut h = 1;
|
||||
'row: while v0 + h < size_v {
|
||||
for k in 0..w {
|
||||
if mask[((v0 + h) * size_u + u0 + k) as usize] != Some(b) {
|
||||
if mask[((v0 + h) * size_u + u0 + k) as usize] != Some(cell) {
|
||||
break 'row;
|
||||
}
|
||||
}
|
||||
|
|
@ -123,25 +186,58 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
let c2 = to_world(u0 + w, v0 + h);
|
||||
let c3 = to_world(u0, v0 + h);
|
||||
|
||||
let color = b.face_color(face);
|
||||
let leaf = if b == Block::Leaves { 1.0 } else { 0.0 };
|
||||
let color = cell.block.face_color(face);
|
||||
let leaf = if cell.block == Block::Leaves { 1.0 } else { 0.0 };
|
||||
let ao_f = [
|
||||
AO_TABLE[cell.ao[0] as usize],
|
||||
AO_TABLE[cell.ao[1] as usize],
|
||||
AO_TABLE[cell.ao[2] as usize],
|
||||
AO_TABLE[cell.ao[3] as usize],
|
||||
];
|
||||
let base_idx = vertices.len() as u32;
|
||||
for c in [c0, c1, c2, c3] {
|
||||
let corners = [c0, c1, c2, c3];
|
||||
for i in 0..4 {
|
||||
vertices.push(Vertex {
|
||||
pos: c,
|
||||
pos: corners[i],
|
||||
color,
|
||||
normal: n_arr,
|
||||
leaf,
|
||||
ao: ao_f[i],
|
||||
});
|
||||
}
|
||||
// Flip the diagonal when AO is "anisotropic" — i.e.
|
||||
// when ao[0]+ao[2] < ao[1]+ao[3]. This stops the
|
||||
// visible diagonal gradient artifact across quads
|
||||
// where the four corners disagree.
|
||||
let flip = ao_f[0] + ao_f[2] < ao_f[1] + ao_f[3];
|
||||
if positive {
|
||||
if flip {
|
||||
indices.extend_from_slice(&[
|
||||
base_idx,
|
||||
base_idx + 1,
|
||||
base_idx + 3,
|
||||
base_idx + 1,
|
||||
base_idx + 2,
|
||||
base_idx + 3,
|
||||
]);
|
||||
} else {
|
||||
indices.extend_from_slice(&[
|
||||
base_idx,
|
||||
base_idx + 1,
|
||||
base_idx + 2,
|
||||
base_idx,
|
||||
base_idx + 2,
|
||||
base_idx + 3,
|
||||
]);
|
||||
}
|
||||
} else if flip {
|
||||
indices.extend_from_slice(&[
|
||||
base_idx,
|
||||
base_idx + 1,
|
||||
base_idx + 2,
|
||||
base_idx,
|
||||
base_idx + 2,
|
||||
base_idx + 3,
|
||||
base_idx + 1,
|
||||
base_idx + 1,
|
||||
base_idx + 3,
|
||||
base_idx + 2,
|
||||
]);
|
||||
} else {
|
||||
indices.extend_from_slice(&[
|
||||
|
|
@ -256,6 +352,61 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_block_has_full_ao() {
|
||||
// A single block in empty space has no occluders, so every vertex
|
||||
// should report fully-open AO (1.0).
|
||||
let world = single_chunk_world(|c| c.set(8, 4, 8, Block::Stone));
|
||||
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
|
||||
let mesh = build_chunk_mesh(&world, chunk);
|
||||
for v in &mesh.vertices {
|
||||
assert!((v.ao - 1.0).abs() < 1e-6, "isolated face vertex ao={} (expected 1.0)", v.ao);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighboring_blocks_darken_shared_corner() {
|
||||
// Two stones with a third sitting above the corner where their +Y
|
||||
// faces meet — that corner must be darker than the corners far
|
||||
// from the occluder.
|
||||
//
|
||||
// y+1: [occ at (5,5,4)]
|
||||
// y: stone@(4,4,4) stone@(5,4,4)
|
||||
//
|
||||
// The corner at world (5, 5, 4) of stone(4,4,4)'s +Y face has an
|
||||
// occluding block touching it from above on the +X side.
|
||||
let world = single_chunk_world(|c| {
|
||||
c.set(4, 4, 4, Block::Stone);
|
||||
c.set(5, 4, 4, Block::Stone);
|
||||
c.set(5, 5, 4, Block::Stone); // occluder above the right stone
|
||||
});
|
||||
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
|
||||
let mesh = build_chunk_mesh(&world, chunk);
|
||||
// The +Y face of stone(4,4,4) — find a vertex near (5,5,4)/(5,5,5)
|
||||
// (the edge touching the occluder) and confirm its ao < 1.0.
|
||||
let mut min_ao_near: f32 = 1.0;
|
||||
let mut min_ao_far: f32 = 1.0;
|
||||
for v in &mesh.vertices {
|
||||
// restrict to top faces (normal.y > 0.5)
|
||||
if v.normal[1] < 0.5 { continue; }
|
||||
// is this vertex on stone(4,4,4) — within its quad bounds?
|
||||
let x = v.pos[0];
|
||||
let z = v.pos[2];
|
||||
if x >= 4.0 - 0.01 && x <= 5.0 + 0.01 && z >= 4.0 - 0.01 && z <= 5.0 + 0.01 {
|
||||
// vertices on the +X edge are close to the occluder
|
||||
if (x - 5.0).abs() < 0.01 {
|
||||
if v.ao < min_ao_near { min_ao_near = v.ao; }
|
||||
}
|
||||
// vertices on the -X edge are far from the occluder
|
||||
if (x - 4.0).abs() < 0.01 {
|
||||
if v.ao < min_ao_far { min_ao_far = v.ao; }
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(min_ao_near < 1.0, "corner adjacent to occluder must be darkened, was {}", min_ao_near);
|
||||
assert!(min_ao_far > min_ao_near, "open corner must be brighter than the occluded one ({} vs {})", min_ao_far, min_ao_near);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_chunk_produces_no_geometry() {
|
||||
let world = single_chunk_world(|_| {});
|
||||
|
|
|
|||
87
src/net/mod.rs
Normal file
87
src/net/mod.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! Pure network message parsing. The shell pulls raw JSON lines out of
|
||||
//! the JS-fed inbox; `parse_inbox` turns them into typed `NetEvent`
|
||||
//! values without touching the world. The shell then folds the events
|
||||
//! into its world / remote-player map.
|
||||
use crate::proto::{EditRec, PlayerInfo, ServerMsg};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NetEvent {
|
||||
Welcome { id: u32, edits: Vec<EditRec> },
|
||||
PlayerList(Vec<PlayerInfo>),
|
||||
Edit(EditRec),
|
||||
Leave { id: u32 },
|
||||
}
|
||||
|
||||
/// Parse a batch of inbox lines into events. Lines that don't deserialize
|
||||
/// as `ServerMsg` are silently dropped — matches the previous tick
|
||||
/// behavior and avoids letting one malformed message take down the
|
||||
/// session.
|
||||
pub fn parse_inbox(lines: Vec<String>) -> Vec<NetEvent> {
|
||||
lines
|
||||
.into_iter()
|
||||
.filter_map(|s| serde_json::from_str::<ServerMsg>(&s).ok())
|
||||
.map(|m| match m {
|
||||
ServerMsg::Welcome { id, edits } => NetEvent::Welcome { id, edits },
|
||||
ServerMsg::Players { list } => NetEvent::PlayerList(list),
|
||||
ServerMsg::Edit { x, y, z, block } => NetEvent::Edit(EditRec { x, y, z, block }),
|
||||
ServerMsg::Leave { id } => NetEvent::Leave { id },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn malformed_lines_are_dropped() {
|
||||
let evs = parse_inbox(vec![
|
||||
"not json".into(),
|
||||
"{\"t\":\"BogusVariant\"}".into(),
|
||||
"".into(),
|
||||
]);
|
||||
assert!(evs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_round_trips() {
|
||||
let line = r#"{"t":"Welcome","id":42,"edits":[]}"#.to_string();
|
||||
let evs = parse_inbox(vec![line]);
|
||||
assert_eq!(evs.len(), 1);
|
||||
match &evs[0] {
|
||||
NetEvent::Welcome { id, edits } => {
|
||||
assert_eq!(*id, 42);
|
||||
assert!(edits.is_empty());
|
||||
}
|
||||
_ => panic!("expected Welcome"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_message_becomes_edit_event() {
|
||||
let line = r#"{"t":"Edit","x":1,"y":2,"z":3,"block":7}"#.to_string();
|
||||
let evs = parse_inbox(vec![line]);
|
||||
assert_eq!(evs.len(), 1);
|
||||
match &evs[0] {
|
||||
NetEvent::Edit(rec) => {
|
||||
assert_eq!(rec.x, 1);
|
||||
assert_eq!(rec.y, 2);
|
||||
assert_eq!(rec.z, 3);
|
||||
assert_eq!(rec.block, 7);
|
||||
}
|
||||
_ => panic!("expected Edit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lines_are_parsed_in_order() {
|
||||
let evs = parse_inbox(vec![
|
||||
r#"{"t":"Welcome","id":1,"edits":[]}"#.into(),
|
||||
"garbage".into(),
|
||||
r#"{"t":"Leave","id":5}"#.into(),
|
||||
]);
|
||||
assert_eq!(evs.len(), 2);
|
||||
assert!(matches!(evs[0], NetEvent::Welcome { id: 1, .. }));
|
||||
assert!(matches!(evs[1], NetEvent::Leave { id: 5 }));
|
||||
}
|
||||
}
|
||||
31
src/post.wgsl
Normal file
31
src/post.wgsl
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Step 1 of the post-process rebuild: minimal pass-through. Samples the
|
||||
// offscreen scene_color and writes it straight to the surface. Effects
|
||||
// (FXAA, sun shafts, tonemap) layer on top of this in later steps.
|
||||
|
||||
@group(0) @binding(0) var scene_color_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var scene_color_sampler: sampler;
|
||||
|
||||
struct PostOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_post(@builtin(vertex_index) idx: u32) -> PostOut {
|
||||
var corners = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 3.0, -1.0),
|
||||
vec2<f32>(-1.0, 3.0),
|
||||
);
|
||||
let p = corners[idx];
|
||||
var out: PostOut;
|
||||
out.clip = vec4<f32>(p, 0.0, 1.0);
|
||||
// Texture origin is top-left; flip Y so screen coords map to texel coords.
|
||||
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_post(in: PostOut) -> @location(0) vec4<f32> {
|
||||
return textureSample(scene_color_tex, scene_color_sampler, in.uv);
|
||||
}
|
||||
216
src/shader.wgsl
216
src/shader.wgsl
|
|
@ -2,33 +2,176 @@ struct Camera {
|
|||
view_proj: mat4x4<f32>,
|
||||
inv_view_proj: mat4x4<f32>,
|
||||
eye: vec4<f32>,
|
||||
/// .x = scene time in seconds (drives day/night cycle + leaf sway)
|
||||
misc: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
|
||||
const SUN_DIR: vec3<f32> = vec3<f32>(0.42, 0.82, 0.39);
|
||||
const SKY_HORIZON: vec3<f32> = vec3<f32>(0.78, 0.88, 0.96);
|
||||
const SKY_ZENITH: vec3<f32> = vec3<f32>(0.30, 0.55, 0.88);
|
||||
const SUN_COLOR: vec3<f32> = vec3<f32>(1.0, 0.95, 0.85);
|
||||
// ---------------- Time-of-day primitives ----------------
|
||||
//
|
||||
// One in-game day takes DAY_PERIOD seconds. The sun sweeps an east-to-west
|
||||
// arc (cos/sin on the same plane) with a small constant tilt on Z so it
|
||||
// isn't dead-flat. Game starts at noon (offset = 0.25 cycles).
|
||||
|
||||
const DAY_PERIOD: f32 = 300.0;
|
||||
const SUN_OFFSET: f32 = 0.25;
|
||||
|
||||
fn sun_direction(t: f32) -> vec3<f32> {
|
||||
let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718;
|
||||
return normalize(vec3<f32>(cos(a), sin(a), 0.25));
|
||||
}
|
||||
|
||||
// Smooth 0..1 going from -0.05 (sun barely under horizon, blue hour) up
|
||||
// to 0.20 (clearly above the horizon, full daylight).
|
||||
fn day_strength(sun: vec3<f32>) -> f32 {
|
||||
return smoothstep(-0.05, 0.20, sun.y);
|
||||
}
|
||||
|
||||
// Twilight peaks while the sun is near the horizon — sunrise + sunset.
|
||||
fn twilight_amount(sun: vec3<f32>) -> f32 {
|
||||
let above = smoothstep(-0.10, 0.05, sun.y);
|
||||
let high = smoothstep(0.05, 0.30, sun.y);
|
||||
return above - high;
|
||||
}
|
||||
|
||||
fn sun_tint(sun: vec3<f32>) -> vec3<f32> {
|
||||
let twi = twilight_amount(sun);
|
||||
return mix(vec3<f32>(1.00, 0.95, 0.85), vec3<f32>(1.00, 0.55, 0.30), twi);
|
||||
}
|
||||
|
||||
// ---------------- Cheap 2D fbm for clouds ----------------
|
||||
|
||||
fn hash21(p: vec2<f32>) -> f32 {
|
||||
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
fn noise2(p: vec2<f32>) -> f32 {
|
||||
let i = floor(p);
|
||||
let f = fract(p);
|
||||
let u = f * f * (3.0 - 2.0 * f);
|
||||
let a = hash21(i);
|
||||
let b = hash21(i + vec2<f32>(1.0, 0.0));
|
||||
let c = hash21(i + vec2<f32>(0.0, 1.0));
|
||||
let d = hash21(i + vec2<f32>(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
fn fbm2(p_in: vec2<f32>) -> f32 {
|
||||
var p = p_in;
|
||||
var v = 0.0;
|
||||
var amp = 0.5;
|
||||
for (var i = 0; i < 4; i = i + 1) {
|
||||
v = v + amp * noise2(p);
|
||||
p = p * 2.07;
|
||||
amp = amp * 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Just the horizon→zenith gradient — no clouds, no sun, no stars. Used by
|
||||
// the terrain shader to compute hemisphere ambient: each fragment samples
|
||||
// the dome in its surface-normal direction so vertical faces inherit the
|
||||
// bright daytime horizon instead of a flat dim ambient.
|
||||
fn sky_dome(dir: vec3<f32>, sun: vec3<f32>) -> vec3<f32> {
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.82, 0.92, 0.99);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
|
||||
let zenith = mix(zenith_night, zenith_day, day);
|
||||
let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi);
|
||||
let up = clamp(dir.y, -1.0, 1.0);
|
||||
let gradient_t = pow(max(up, 0.0), 0.55);
|
||||
return mix(horizon, zenith, gradient_t);
|
||||
}
|
||||
|
||||
// Cheap "stars" — high-frequency hash on view direction, threshold to
|
||||
// keep only ~0.2% of cells lit.
|
||||
fn star_field(dir: vec3<f32>) -> f32 {
|
||||
if (dir.y <= 0.0) { return 0.0; }
|
||||
let cell = floor(dir * 220.0);
|
||||
let h = fract(sin(dot(cell, vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
|
||||
return step(0.997, h);
|
||||
}
|
||||
|
||||
// ---------------- Sky ----------------
|
||||
//
|
||||
// `dir` is the *view* direction from camera into the scene (unit vector).
|
||||
// Composes a horizon→zenith gradient that re-tones with sun height,
|
||||
// twinklers + cloud streaks + sun + moon discs.
|
||||
|
||||
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
||||
let t = camera.misc.x;
|
||||
let sun = sun_direction(t);
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.78, 0.88, 0.96);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
|
||||
|
||||
let zenith = mix(zenith_night, zenith_day, day);
|
||||
let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi);
|
||||
|
||||
let up = clamp(dir.y, -1.0, 1.0);
|
||||
let t = pow(max(up, 0.0), 0.55);
|
||||
let base = mix(SKY_HORIZON, SKY_ZENITH, t);
|
||||
// Slight darken below horizon (mostly never seen, but soft).
|
||||
let gradient_t = pow(max(up, 0.0), 0.55);
|
||||
var sky = mix(horizon, zenith, gradient_t);
|
||||
|
||||
// Below-horizon slight darken so the world below the player still feels grounded.
|
||||
let below = step(up, 0.0) * 0.2;
|
||||
let s = max(dot(normalize(dir), SUN_DIR), 0.0);
|
||||
let disc = pow(s, 800.0) * 1.4;
|
||||
let halo = pow(s, 6.0) * 0.18;
|
||||
return base * (1.0 - below) + SUN_COLOR * (disc + halo);
|
||||
sky = sky * (1.0 - below);
|
||||
|
||||
// Stars: fade in as day strength drops. Slight twinkle via time-based jitter.
|
||||
let night_amt = clamp(1.0 - day, 0.0, 1.0);
|
||||
if (night_amt > 0.05) {
|
||||
let st = star_field(dir);
|
||||
let twinkle = 0.7 + 0.3 * sin(t * 6.0 + dir.x * 100.0 + dir.z * 130.0);
|
||||
sky = sky + vec3<f32>(st * night_amt * twinkle);
|
||||
}
|
||||
|
||||
// Cloud layer — fbm scrolled across an imaginary plane high above. Only
|
||||
// visible looking upward (dir.y > 0). Cheap: 4 octaves of value noise.
|
||||
if (dir.y > 0.05) {
|
||||
let proj = dir.xz / dir.y;
|
||||
let scroll = vec2<f32>(t * 0.004, t * 0.0015);
|
||||
let n = fbm2(proj * 0.50 + scroll);
|
||||
let mask = smoothstep(0.50, 0.78, n);
|
||||
let cloud_lit = mix(vec3<f32>(0.30, 0.30, 0.35), vec3<f32>(1.00, 0.97, 0.92), day);
|
||||
let cloud_twi = vec3<f32>(1.00, 0.60, 0.45);
|
||||
let cloud_col = mix(cloud_lit, cloud_twi, twi * 0.7);
|
||||
sky = mix(sky, cloud_col, mask * (0.55 + 0.25 * day));
|
||||
}
|
||||
|
||||
// Sun disc + halo. Disc only visible in daytime (no sun glow underground).
|
||||
let sun_col = sun_tint(sun);
|
||||
let cos_s = max(dot(dir, sun), 0.0);
|
||||
let disc = pow(cos_s, 800.0) * 1.5 * smoothstep(-0.05, 0.05, sun.y);
|
||||
let halo = pow(cos_s, 5.0) * 0.20 * day;
|
||||
sky = sky + sun_col * (disc + halo);
|
||||
|
||||
// Moon disc — opposite the sun, faint white. Only at night.
|
||||
let moon = -sun;
|
||||
let cos_m = max(dot(dir, moon), 0.0);
|
||||
let moon_disc = pow(cos_m, 700.0) * 0.9;
|
||||
let moon_halo = pow(cos_m, 24.0) * 0.06;
|
||||
sky = sky + vec3<f32>(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night_amt;
|
||||
|
||||
return sky;
|
||||
}
|
||||
|
||||
// ---------------- Terrain ----------------
|
||||
|
||||
struct VsIn {
|
||||
@location(0) pos: vec3<f32>,
|
||||
@location(1) color: vec3<f32>,
|
||||
@location(2) normal: vec3<f32>,
|
||||
@location(3) leaf: f32,
|
||||
@location(4) ao: f32,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
|
|
@ -37,6 +180,7 @@ struct VsOut {
|
|||
@location(1) color: vec3<f32>,
|
||||
@location(2) normal: vec3<f32>,
|
||||
@location(3) leaf: f32,
|
||||
@location(4) ao: f32,
|
||||
};
|
||||
|
||||
@vertex
|
||||
|
|
@ -57,17 +201,46 @@ fn vs_main(in: VsIn) -> VsOut {
|
|||
out.color = in.color;
|
||||
out.normal = in.normal;
|
||||
out.leaf = in.leaf;
|
||||
out.ao = in.ao;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let n = normalize(in.normal);
|
||||
let ndl = max(dot(n, SUN_DIR), 0.0);
|
||||
let ambient = 0.40;
|
||||
var lit = in.color * (ambient + (1.0 - ambient) * ndl);
|
||||
let t = camera.misc.x;
|
||||
let sun = sun_direction(t);
|
||||
let day = day_strength(sun);
|
||||
let sun_col = sun_tint(sun);
|
||||
|
||||
// Cheap procedural noise for leaves so the canopy doesn't look uniform.
|
||||
let n = normalize(in.normal);
|
||||
let ndl = max(dot(n, sun), 0.0);
|
||||
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
|
||||
let sun_term = ndl * sun_visible;
|
||||
|
||||
// Hemisphere ambient — *sample* the sky dome in the normal direction
|
||||
// instead of lerping two constants. A vertical face (n.y ≈ 0) picks up
|
||||
// the bright horizon, a top face (n.y ≈ 1) the (darker) zenith, a
|
||||
// bottom face the earth-bounce. This is the cheap analogue of an
|
||||
// integrated environment light and is what makes daytime sides not
|
||||
// look like night.
|
||||
let sky_in_normal = sky_dome(n, sun);
|
||||
let earth_down_day = vec3<f32>(0.20, 0.18, 0.14);
|
||||
let earth_down_night = vec3<f32>(0.03, 0.03, 0.04);
|
||||
let earth_down = mix(earth_down_night, earth_down_day, day);
|
||||
let face_up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
let ambient_col = mix(earth_down, sky_in_normal, face_up);
|
||||
// Higher strength than before — outdoor diffuse skylight is roughly
|
||||
// 10–20% of direct sun in reality. The old 0.45 cap was making sides
|
||||
// read as if it were dusk during the day.
|
||||
let ambient_strength = mix(0.25, 0.85, day);
|
||||
|
||||
let lighting = ambient_col * ambient_strength + sun_col * sun_term;
|
||||
var lit = in.color * lighting;
|
||||
|
||||
// Bake-time per-vertex ambient occlusion.
|
||||
lit = lit * in.ao;
|
||||
|
||||
// Per-pixel value noise on leaves so the canopy doesn't look uniform.
|
||||
if (in.leaf > 0.5) {
|
||||
let n2 = fract(sin(dot(floor(in.world_pos * 1.3), vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
|
||||
lit = lit * (0.88 + n2 * 0.18);
|
||||
|
|
@ -76,12 +249,18 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
|||
let to_eye = camera.eye.xyz - in.world_pos;
|
||||
let dist = length(to_eye);
|
||||
let view_dir = -to_eye / max(dist, 0.0001);
|
||||
let sky = sky_color(-view_dir);
|
||||
|
||||
let fog_start = 90.0;
|
||||
let fog_end = 320.0;
|
||||
let fog_t = clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0);
|
||||
let color = mix(lit, sky, fog_t);
|
||||
var color = lit;
|
||||
if (fog_t > 0.001) {
|
||||
// Only pay for the full sky lookup if the fragment is actually
|
||||
// fogged enough to read it. Saves the cloud/fbm cost on near
|
||||
// geometry.
|
||||
let sky = sky_color(-view_dir);
|
||||
color = mix(lit, sky, fog_t);
|
||||
}
|
||||
|
||||
return vec4<f32>(color, 1.0);
|
||||
}
|
||||
|
|
@ -126,3 +305,4 @@ fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
|
|||
fn fs_outline() -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(0.05, 0.05, 0.07, 1.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
90
src/sim/body.rs
Normal file
90
src/sim/body.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
//! The player's physical body — position, velocity, health. Passed by
|
||||
//! value through pure transitions so a tick's update is
|
||||
//! `body.step(...)` returning a new body rather than mutating fields
|
||||
//! across a 200-line function.
|
||||
use glam::Vec3;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlayerBody {
|
||||
pub feet: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
pub max_y_since_ground: f32,
|
||||
pub hp: u8,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
impl PlayerBody {
|
||||
pub const MAX_HP: u8 = 20;
|
||||
|
||||
pub fn spawned_at(feet: Vec3) -> Self {
|
||||
Self {
|
||||
feet,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
max_y_since_ground: feet.y,
|
||||
hp: Self::MAX_HP,
|
||||
alive: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_damage(self, d: u8) -> Self {
|
||||
if !self.alive {
|
||||
return self;
|
||||
}
|
||||
let hp = self.hp.saturating_sub(d);
|
||||
Self {
|
||||
hp,
|
||||
alive: hp > 0,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn respawned_at(feet: Vec3) -> Self {
|
||||
Self::spawned_at(feet)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn damage_reduces_hp_and_kills_at_zero() {
|
||||
let b = PlayerBody::spawned_at(Vec3::ZERO);
|
||||
assert_eq!(b.hp, 20);
|
||||
let b = b.take_damage(5);
|
||||
assert_eq!(b.hp, 15);
|
||||
assert!(b.alive);
|
||||
let b = b.take_damage(20); // overkill
|
||||
assert_eq!(b.hp, 0);
|
||||
assert!(!b.alive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_body_is_immune_to_further_damage() {
|
||||
let dead = PlayerBody::spawned_at(Vec3::ZERO).take_damage(20);
|
||||
assert!(!dead.alive);
|
||||
let still_dead = dead.take_damage(50);
|
||||
assert_eq!(still_dead.hp, 0);
|
||||
assert!(!still_dead.alive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respawn_restores_full_hp_and_zero_velocity() {
|
||||
let battered = PlayerBody {
|
||||
feet: Vec3::new(1.0, 2.0, 3.0),
|
||||
velocity: Vec3::new(5.0, -10.0, 0.0),
|
||||
on_ground: false,
|
||||
max_y_since_ground: 50.0,
|
||||
hp: 3,
|
||||
alive: false,
|
||||
};
|
||||
let _ = battered; // we don't reuse it; respawn doesn't depend on prior state by design
|
||||
let fresh = PlayerBody::respawned_at(Vec3::new(0.5, 64.0, 0.5));
|
||||
assert_eq!(fresh.hp, 20);
|
||||
assert!(fresh.alive);
|
||||
assert_eq!(fresh.velocity, Vec3::ZERO);
|
||||
assert!(!fresh.on_ground);
|
||||
}
|
||||
}
|
||||
160
src/sim/collision.rs
Normal file
160
src/sim/collision.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! AABB collision primitives. `sweep_axis` is the load-bearing function:
|
||||
//! one axis at a time, capped per-substep, snap-on-hit. Returning
|
||||
//! `(Vec3, bool)` instead of mutating `&mut Vec3` is the small but
|
||||
//! visible step toward a pure-pipeline shape.
|
||||
use crate::world::World;
|
||||
use glam::{IVec3, Vec3};
|
||||
|
||||
pub const PLAYER_HALF_W: f32 = 0.3;
|
||||
pub const PLAYER_HEIGHT: f32 = 1.8;
|
||||
pub const EYE_HEIGHT: f32 = 1.62;
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
pub struct AabbI {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl AabbI {
|
||||
pub fn block(p: IVec3) -> Self {
|
||||
Self {
|
||||
min: Vec3::new(p.x as f32, p.y as f32, p.z as f32),
|
||||
max: Vec3::new(p.x as f32 + 1.0, p.y as f32 + 1.0, p.z as f32 + 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aabb_overlap_player(b: AabbI, feet: Vec3) -> bool {
|
||||
let p_min = Vec3::new(feet.x - PLAYER_HALF_W, feet.y, feet.z - PLAYER_HALF_W);
|
||||
let p_max = Vec3::new(
|
||||
feet.x + PLAYER_HALF_W,
|
||||
feet.y + PLAYER_HEIGHT,
|
||||
feet.z + PLAYER_HALF_W,
|
||||
);
|
||||
p_min.x < b.max.x
|
||||
&& p_max.x > b.min.x
|
||||
&& p_min.y < b.max.y
|
||||
&& p_max.y > b.min.y
|
||||
&& p_min.z < b.max.z
|
||||
&& p_max.z > b.min.z
|
||||
}
|
||||
|
||||
pub fn player_overlaps_solid(world: &World, feet: Vec3) -> bool {
|
||||
let eps = 0.0;
|
||||
let min_x = (feet.x - PLAYER_HALF_W + eps).floor() as i32;
|
||||
let max_x = (feet.x + PLAYER_HALF_W - eps).floor() as i32;
|
||||
let min_y = feet.y.floor() as i32;
|
||||
let max_y = (feet.y + PLAYER_HEIGHT - 0.001).floor() as i32;
|
||||
let min_z = (feet.z - PLAYER_HALF_W + eps).floor() as i32;
|
||||
let max_z = (feet.z + PLAYER_HALF_W - eps).floor() as i32;
|
||||
for x in min_x..=max_x {
|
||||
for y in min_y..=max_y {
|
||||
for z in min_z..=max_z {
|
||||
if world.get_block(IVec3::new(x, y, z)).solid() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Sweep the player AABB along `axis` by `delta`, snapping against the
|
||||
/// first solid face encountered. Sub-steps are capped below one block so
|
||||
/// the single-face snap is always correct — a one-shot snap at high
|
||||
/// terminal-velocity falls could otherwise place the player *inside*
|
||||
/// terrain.
|
||||
///
|
||||
/// Pure shape: `(World, Vec3, f32, Axis) -> (Vec3, bool)`.
|
||||
pub fn sweep_axis(world: &World, feet: Vec3, delta: f32, axis: Axis) -> (Vec3, bool) {
|
||||
if delta == 0.0 {
|
||||
return (feet, false);
|
||||
}
|
||||
const MAX_STEP: f32 = 0.45;
|
||||
let n = (delta.abs() / MAX_STEP).ceil().max(1.0) as i32;
|
||||
let step = delta / n as f32;
|
||||
let eps = 0.001;
|
||||
let mut feet = feet;
|
||||
for _ in 0..n {
|
||||
let candidate = match axis {
|
||||
Axis::X => Vec3::new(feet.x + step, feet.y, feet.z),
|
||||
Axis::Y => Vec3::new(feet.x, feet.y + step, feet.z),
|
||||
Axis::Z => Vec3::new(feet.x, feet.y, feet.z + step),
|
||||
};
|
||||
if !player_overlaps_solid(world, candidate) {
|
||||
feet = candidate;
|
||||
continue;
|
||||
}
|
||||
let snapped = match axis {
|
||||
Axis::X => Vec3::new(
|
||||
if step > 0.0 {
|
||||
(candidate.x + PLAYER_HALF_W).floor() - PLAYER_HALF_W - eps
|
||||
} else {
|
||||
(candidate.x - PLAYER_HALF_W).floor() + 1.0 + PLAYER_HALF_W + eps
|
||||
},
|
||||
feet.y,
|
||||
feet.z,
|
||||
),
|
||||
Axis::Z => Vec3::new(
|
||||
feet.x,
|
||||
feet.y,
|
||||
if step > 0.0 {
|
||||
(candidate.z + PLAYER_HALF_W).floor() - PLAYER_HALF_W - eps
|
||||
} else {
|
||||
(candidate.z - PLAYER_HALF_W).floor() + 1.0 + PLAYER_HALF_W + eps
|
||||
},
|
||||
),
|
||||
Axis::Y => Vec3::new(
|
||||
feet.x,
|
||||
if step > 0.0 {
|
||||
(candidate.y + PLAYER_HEIGHT).floor() - PLAYER_HEIGHT - eps
|
||||
} else {
|
||||
candidate.y.floor() + 1.0 + eps
|
||||
},
|
||||
feet.z,
|
||||
),
|
||||
};
|
||||
return (snapped, true);
|
||||
}
|
||||
(feet, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::natural_surface_y;
|
||||
|
||||
#[test]
|
||||
fn passes_freely_through_air() {
|
||||
let world = World::new();
|
||||
let (feet, hit) = sweep_axis(&world, Vec3::new(0.5, 60.0, 0.5), -1.0, Axis::Y);
|
||||
assert!(!hit);
|
||||
assert!((feet.y - 59.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_against_ground() {
|
||||
let world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
let start = Vec3::new(0.5, (surface + 1) as f32 + 0.01, 0.5);
|
||||
let (feet, hit) = sweep_axis(&world, start, -5.0, Axis::Y);
|
||||
assert!(hit);
|
||||
assert!(feet.y >= (surface + 1) as f32);
|
||||
assert!(feet.y < (surface + 1) as f32 + 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_enters_a_solid_block() {
|
||||
let world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
let start = Vec3::new(0.5, (surface + 1) as f32, 0.5);
|
||||
let (feet, _) = sweep_axis(&world, start, -100.0, Axis::Y);
|
||||
assert!(!player_overlaps_solid(&world, feet));
|
||||
}
|
||||
}
|
||||
81
src/sim/edit.rs
Normal file
81
src/sim/edit.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Block-edit primitives: u8 → Block, applying an edit, and the set of
|
||||
//! chunks that touching a single block invalidates.
|
||||
use crate::proto::EditRec;
|
||||
use crate::world::{Block, Face, World};
|
||||
use glam::IVec3;
|
||||
|
||||
pub fn block_from_u8(b: u8) -> Block {
|
||||
match b {
|
||||
x if x == Block::Grass as u8 => Block::Grass,
|
||||
x if x == Block::Dirt as u8 => Block::Dirt,
|
||||
x if x == Block::Stone as u8 => Block::Stone,
|
||||
x if x == Block::Sand as u8 => Block::Sand,
|
||||
x if x == Block::Wood as u8 => Block::Wood,
|
||||
x if x == Block::Leaves as u8 => Block::Leaves,
|
||||
x if x == Block::Cobble as u8 => Block::Cobble,
|
||||
x if x == Block::Brick as u8 => Block::Brick,
|
||||
x if x == Block::Snow as u8 => Block::Snow,
|
||||
x if x == Block::Ice as u8 => Block::Ice,
|
||||
_ => Block::Stone,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a single `EditRec` to the world. Returns whether anything
|
||||
/// changed. Mutates world — this is the lowest-level imperative call;
|
||||
/// callers compose it with `chunks_for_edit` to know which meshes to
|
||||
/// rebuild.
|
||||
pub fn apply_edit(world: &mut World, e: &EditRec) -> bool {
|
||||
let block = if e.block == 0 {
|
||||
Block::Air
|
||||
} else {
|
||||
block_from_u8(e.block)
|
||||
};
|
||||
world.set_block(IVec3::new(e.x, e.y, e.z), block)
|
||||
}
|
||||
|
||||
/// The chunks whose meshes need rebuilding after the block at `p` is
|
||||
/// edited: the chunk containing `p`, plus any neighbor chunk that
|
||||
/// touches `p`'s 6 faces.
|
||||
pub fn chunks_for_edit(p: IVec3) -> Vec<IVec3> {
|
||||
let (c, _) = World::block_to_chunk(p);
|
||||
let mut out = vec![c];
|
||||
for face in Face::ALL {
|
||||
let n = p + face.normal();
|
||||
let (nc, _) = World::block_to_chunk(n);
|
||||
if nc != c && !out.contains(&nc) {
|
||||
out.push(nc);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn u8_roundtrips_for_every_hotbar_slot() {
|
||||
let expected: &[(u8, Block)] = &[
|
||||
(1, Block::Grass),
|
||||
(2, Block::Dirt),
|
||||
(3, Block::Stone),
|
||||
(4, Block::Sand),
|
||||
(5, Block::Wood),
|
||||
(6, Block::Leaves),
|
||||
(7, Block::Cobble),
|
||||
(8, Block::Brick),
|
||||
(9, Block::Snow),
|
||||
(10, Block::Ice),
|
||||
];
|
||||
for &(u, b) in expected {
|
||||
assert_eq!(block_from_u8(u), b, "slot {} must map to {:?}", u, b);
|
||||
assert!(b.solid());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u8_falls_back_to_stone_on_garbage() {
|
||||
assert_eq!(block_from_u8(99), Block::Stone);
|
||||
assert_eq!(block_from_u8(255), Block::Stone);
|
||||
}
|
||||
}
|
||||
19
src/sim/event.rs
Normal file
19
src/sim/event.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! Side-effects emitted by the sim layer. The shell consumes the list
|
||||
//! returned from `step_movement` and applies these to the renderer / HP
|
||||
//! bookkeeping / network outbox.
|
||||
use crate::proto::EditRec;
|
||||
use glam::IVec3;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SimEvent {
|
||||
/// Player just landed after a fall of `fall_dist` blocks. The shell
|
||||
/// converts this to damage via `sim::spawn::fall_damage`.
|
||||
Landed { fall_dist: f32 },
|
||||
/// Player crossed the void floor; force max damage.
|
||||
VoidDeath,
|
||||
/// A block edit succeeded and needs to be rebuilt + broadcast.
|
||||
BlockEdited {
|
||||
edit: EditRec,
|
||||
dirty_chunks: Vec<IVec3>,
|
||||
},
|
||||
}
|
||||
123
src/sim/input.rs
Normal file
123
src/sim/input.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
//! Input snapshots and the touch/controller bridge.
|
||||
//!
|
||||
//! `TouchBridge` is a plain data struct that the wasm bindings store in
|
||||
//! a `RefCell` in `crate::state::wasm_api` — this module only knows the
|
||||
//! shape so the merge functions can be tested without any browser.
|
||||
use crate::camera::KbHeld;
|
||||
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub struct TouchBridge {
|
||||
pub touch_mode: bool,
|
||||
pub forward: bool,
|
||||
pub back: bool,
|
||||
pub left: bool,
|
||||
pub right: bool,
|
||||
pub jump: bool,
|
||||
pub sprint: bool,
|
||||
pub look_dx: f32,
|
||||
pub look_dy: f32,
|
||||
pub break_pressed: bool,
|
||||
pub place_pressed: bool,
|
||||
pub selected: Option<u8>,
|
||||
}
|
||||
|
||||
/// Snapshot of all input for one tick. The shell builds this once per
|
||||
/// frame, then passes it through `step_movement`. Held flags are the
|
||||
/// merged result of keyboard + bridge; one-shots are consumed.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct Input {
|
||||
pub held: KbHeld,
|
||||
pub look_dx: f32,
|
||||
pub look_dy: f32,
|
||||
pub primary: bool,
|
||||
pub secondary: bool,
|
||||
pub selected_block: u8,
|
||||
}
|
||||
|
||||
/// Pure: combine sticky keyboard hold state with the live touch / gamepad
|
||||
/// bridge. The "release the joystick and the player stops" property
|
||||
/// hinges on this being recomputed fresh every tick — never folded back
|
||||
/// into a persistent field, which was the source of the original
|
||||
/// sticky-input bug.
|
||||
pub fn merge_held(kb: &KbHeld, br: &TouchBridge) -> KbHeld {
|
||||
KbHeld {
|
||||
forward: kb.forward || br.forward,
|
||||
back: kb.back || br.back,
|
||||
left: kb.left || br.left,
|
||||
right: kb.right || br.right,
|
||||
up: kb.up || br.jump,
|
||||
down: kb.down,
|
||||
sprint: kb.sprint || br.sprint,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn passes_through_keyboard_alone() {
|
||||
let kb = KbHeld {
|
||||
forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
let br = TouchBridge::default();
|
||||
let m = merge_held(&kb, &br);
|
||||
assert!(m.forward);
|
||||
assert!(!m.back);
|
||||
assert!(!m.up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_bridge_alone() {
|
||||
let kb = KbHeld::default();
|
||||
let br = TouchBridge {
|
||||
forward: true,
|
||||
jump: true,
|
||||
..Default::default()
|
||||
};
|
||||
let m = merge_held(&kb, &br);
|
||||
assert!(m.forward);
|
||||
assert!(m.up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_when_bridge_releases() {
|
||||
let kb = KbHeld::default();
|
||||
let br_pressed = TouchBridge {
|
||||
forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
let br_released = TouchBridge::default();
|
||||
assert!(merge_held(&kb, &br_pressed).forward);
|
||||
// Crucial: stepping from "bridge held" to "bridge released" must
|
||||
// immediately read as not-pressed. The pre-fix code failed this
|
||||
// because it folded the prior `true` into a persistent field via OR.
|
||||
assert!(!merge_held(&kb, &br_released).forward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_jump_too() {
|
||||
let kb = KbHeld::default();
|
||||
let br_jumping = TouchBridge {
|
||||
jump: true,
|
||||
..Default::default()
|
||||
};
|
||||
let br_idle = TouchBridge::default();
|
||||
assert!(merge_held(&kb, &br_jumping).up);
|
||||
assert!(
|
||||
!merge_held(&kb, &br_idle).up,
|
||||
"releasing the jump button must clear `up` so the player stops bouncing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kb_wins_even_if_bridge_drops() {
|
||||
let kb = KbHeld {
|
||||
forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
let br = TouchBridge::default();
|
||||
assert!(merge_held(&kb, &br).forward);
|
||||
}
|
||||
}
|
||||
27
src/sim/mod.rs
Normal file
27
src/sim/mod.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//! Pure simulation core. No GPU, no winit, no thread-locals — every
|
||||
//! function here is a value-in / value-out transformation that can be
|
||||
//! tested without a render context.
|
||||
//!
|
||||
//! Categorical shape:
|
||||
//!
|
||||
//! ```text
|
||||
//! (World, PlayerBody, MoveInput, dt) ──step_movement──▶ (PlayerBody', [SimEvent])
|
||||
//! (World, EditRec) ──apply_edit────▶ bool (mut world)
|
||||
//! (KbHeld, TouchBridge) ──merge_held────▶ KbHeld
|
||||
//! ([inbox line]) ──parse_inbox───▶ [NetEvent] (in `crate::net`)
|
||||
//! ```
|
||||
//!
|
||||
//! The imperative shell in `crate::state` is the only place these
|
||||
//! morphisms are composed against the real World/Renderer/network.
|
||||
pub mod body;
|
||||
pub mod collision;
|
||||
pub mod edit;
|
||||
pub mod event;
|
||||
pub mod input;
|
||||
pub mod physics;
|
||||
pub mod spawn;
|
||||
|
||||
pub use body::PlayerBody;
|
||||
pub use event::SimEvent;
|
||||
pub use input::{merge_held, Input, TouchBridge};
|
||||
pub use physics::{step_movement, MoveInput, MoveOutcome};
|
||||
251
src/sim/physics.rs
Normal file
251
src/sim/physics.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
//! The physics step — the single highest-leverage extract from the
|
||||
//! imperative tick. Given a body, world, and held inputs, return the
|
||||
//! next body plus any events the shell needs to act on.
|
||||
//!
|
||||
//! Total function: `(World, PlayerBody, MoveInput) -> MoveOutcome`.
|
||||
use crate::camera::KbHeld;
|
||||
use crate::sim::body::PlayerBody;
|
||||
use crate::sim::collision::{sweep_axis, Axis};
|
||||
use crate::sim::event::SimEvent;
|
||||
use glam::Vec3;
|
||||
|
||||
pub const GRAVITY: f32 = -30.0;
|
||||
pub const JUMP_VEL: f32 = 9.0;
|
||||
pub const TERMINAL_VEL: f32 = -55.0;
|
||||
pub const WALK_SPEED: f32 = 4.6;
|
||||
pub const SPRINT_SPEED: f32 = 7.5;
|
||||
/// Below this Y, the player is in the void and dies outright.
|
||||
pub const VOID_Y: f32 = -25.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MoveInput {
|
||||
pub held: KbHeld,
|
||||
pub forward_flat: Vec3,
|
||||
pub right_flat: Vec3,
|
||||
pub dt: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MoveOutcome {
|
||||
pub body: PlayerBody,
|
||||
pub events: Vec<SimEvent>,
|
||||
}
|
||||
|
||||
/// Integrate one tick of movement + collision. Pure aside from reading
|
||||
/// `world` for collision queries; produces a new body value and a list
|
||||
/// of events.
|
||||
pub fn step_movement(world: &crate::world::World, body: PlayerBody, input: MoveInput) -> MoveOutcome {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if !body.alive {
|
||||
return MoveOutcome { body, events };
|
||||
}
|
||||
|
||||
let dt = input.dt;
|
||||
let held = &input.held;
|
||||
|
||||
// Horizontal wish vector built from facing × held flags.
|
||||
let mut wish = Vec3::ZERO;
|
||||
if held.forward {
|
||||
wish += input.forward_flat;
|
||||
}
|
||||
if held.back {
|
||||
wish -= input.forward_flat;
|
||||
}
|
||||
if held.right {
|
||||
wish += input.right_flat;
|
||||
}
|
||||
if held.left {
|
||||
wish -= input.right_flat;
|
||||
}
|
||||
let wish = wish.normalize_or_zero();
|
||||
let speed = if held.sprint { SPRINT_SPEED } else { WALK_SPEED };
|
||||
|
||||
let mut velocity = Vec3::new(wish.x * speed, body.velocity.y, wish.z * speed);
|
||||
|
||||
// Jump: only when grounded.
|
||||
let mut on_ground = body.on_ground;
|
||||
if held.up && on_ground {
|
||||
velocity.y = JUMP_VEL;
|
||||
on_ground = false;
|
||||
}
|
||||
|
||||
// Gravity + terminal velocity.
|
||||
velocity.y = (velocity.y + GRAVITY * dt).max(TERMINAL_VEL);
|
||||
|
||||
// Collision sweeps. The Y-result's hit flag tells us whether we
|
||||
// collided this tick — if delta.y < 0 that's landing.
|
||||
let was_on_ground = on_ground;
|
||||
let mut feet = body.feet;
|
||||
let delta = velocity * dt;
|
||||
|
||||
let (f1, _) = sweep_axis(world, feet, delta.x, Axis::X);
|
||||
feet = f1;
|
||||
let (f2, _) = sweep_axis(world, feet, delta.z, Axis::Z);
|
||||
feet = f2;
|
||||
let (f3, y_hit) = sweep_axis(world, feet, delta.y, Axis::Y);
|
||||
feet = f3;
|
||||
|
||||
if y_hit {
|
||||
if delta.y < 0.0 {
|
||||
on_ground = true;
|
||||
}
|
||||
velocity.y = 0.0;
|
||||
} else if delta.y != 0.0 {
|
||||
on_ground = false;
|
||||
}
|
||||
|
||||
// Track the highest Y reached since leaving the ground for fall damage.
|
||||
let max_y_since_ground = if was_on_ground && !on_ground {
|
||||
feet.y
|
||||
} else if !on_ground {
|
||||
body.max_y_since_ground.max(feet.y)
|
||||
} else {
|
||||
body.max_y_since_ground
|
||||
};
|
||||
|
||||
if !was_on_ground && on_ground {
|
||||
let dist = (max_y_since_ground - feet.y).max(0.0);
|
||||
if dist > 0.0 {
|
||||
events.push(SimEvent::Landed { fall_dist: dist });
|
||||
}
|
||||
}
|
||||
|
||||
if feet.y < VOID_Y {
|
||||
events.push(SimEvent::VoidDeath);
|
||||
}
|
||||
|
||||
MoveOutcome {
|
||||
body: PlayerBody {
|
||||
feet,
|
||||
velocity,
|
||||
on_ground,
|
||||
max_y_since_ground,
|
||||
hp: body.hp,
|
||||
alive: body.alive,
|
||||
},
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::{natural_surface_y, World};
|
||||
|
||||
fn idle_input(dt: f32) -> MoveInput {
|
||||
MoveInput {
|
||||
held: KbHeld::default(),
|
||||
forward_flat: Vec3::new(1.0, 0.0, 0.0),
|
||||
right_flat: Vec3::new(0.0, 0.0, 1.0),
|
||||
dt,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn airborne_body_accumulates_gravity() {
|
||||
let world = World::new();
|
||||
let start = PlayerBody {
|
||||
feet: Vec3::new(0.5, 60.0, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
max_y_since_ground: 60.0,
|
||||
hp: 20,
|
||||
alive: true,
|
||||
};
|
||||
let out = step_movement(&world, start, idle_input(0.1));
|
||||
assert!(out.body.velocity.y < 0.0, "gravity must pull down");
|
||||
assert!(out.body.feet.y < 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_only_works_when_grounded() {
|
||||
let world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
let mut input = idle_input(0.016);
|
||||
input.held.up = true;
|
||||
|
||||
// Airborne: jump ignored.
|
||||
let airborne = PlayerBody {
|
||||
feet: Vec3::new(0.5, 200.0, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
..PlayerBody::spawned_at(Vec3::new(0.5, 200.0, 0.5))
|
||||
};
|
||||
let out = step_movement(&world, airborne, input.clone());
|
||||
assert!(out.body.velocity.y <= 0.0, "no jump while airborne");
|
||||
|
||||
// Grounded: jump engages.
|
||||
let grounded = PlayerBody {
|
||||
feet: Vec3::new(0.5, (surface + 1) as f32, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
..PlayerBody::spawned_at(Vec3::new(0.5, (surface + 1) as f32, 0.5))
|
||||
};
|
||||
let out = step_movement(&world, grounded, input);
|
||||
assert!(out.body.velocity.y > 0.0, "jump must yield upward velocity");
|
||||
assert!(!out.body.on_ground, "leaving the ground breaks contact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_fall_emits_landed_event() {
|
||||
let world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
// Spawn 30 blocks above ground, then step until we land. We loop
|
||||
// because a single tick at low dt won't cover the fall.
|
||||
let mut body = PlayerBody {
|
||||
feet: Vec3::new(0.5, (surface + 30) as f32, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
max_y_since_ground: (surface + 30) as f32,
|
||||
hp: 20,
|
||||
alive: true,
|
||||
};
|
||||
let mut saw_landing = false;
|
||||
for _ in 0..200 {
|
||||
let out = step_movement(&world, body, idle_input(0.05));
|
||||
body = out.body;
|
||||
if out.events.iter().any(|e| matches!(e, SimEvent::Landed { .. })) {
|
||||
saw_landing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_landing, "must observe a Landed event after a long fall");
|
||||
assert!(body.on_ground);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn void_floor_emits_void_death() {
|
||||
// Place the player below the void Y with downward velocity; even
|
||||
// though the world's chunks don't extend that far, the void
|
||||
// check should still fire.
|
||||
let world = World::new();
|
||||
let body = PlayerBody {
|
||||
feet: Vec3::new(0.5, -30.0, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
max_y_since_ground: -30.0,
|
||||
hp: 20,
|
||||
alive: true,
|
||||
};
|
||||
let out = step_movement(&world, body, idle_input(0.05));
|
||||
assert!(out.events.iter().any(|e| matches!(e, SimEvent::VoidDeath)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_body_does_not_move() {
|
||||
let world = World::new();
|
||||
let body = PlayerBody {
|
||||
feet: Vec3::new(5.0, 100.0, 5.0),
|
||||
velocity: Vec3::new(10.0, -5.0, 0.0),
|
||||
on_ground: false,
|
||||
max_y_since_ground: 100.0,
|
||||
hp: 0,
|
||||
alive: false,
|
||||
};
|
||||
let out = step_movement(&world, body, idle_input(0.1));
|
||||
assert_eq!(out.body.feet, body.feet);
|
||||
assert_eq!(out.body.velocity, body.velocity);
|
||||
assert!(out.events.is_empty());
|
||||
}
|
||||
}
|
||||
114
src/sim/spawn.rs
Normal file
114
src/sim/spawn.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Spawn-point selection and fall-damage. Both are pure functions of
|
||||
//! world state and a scalar input, so they're easy to pin with
|
||||
//! regression tests.
|
||||
use crate::world::{natural_surface_y, World, CHUNK_HEIGHT};
|
||||
use glam::{IVec3, Vec3};
|
||||
|
||||
/// Returns the player feet position to spawn at. Anchored to the
|
||||
/// *natural* terrain height computed from the same noise the generator
|
||||
/// uses, so player edits at spawn (towers, holes) don't permanently
|
||||
/// move the spawn point. Only scans upward from the natural surface if
|
||||
/// a tower currently blocks it.
|
||||
pub fn find_safe_spawn(world: &World) -> Vec3 {
|
||||
let (x, z) = (0_i32, 0_i32);
|
||||
let surface_y = natural_surface_y(x, z);
|
||||
let mut feet_y = surface_y + 1;
|
||||
let max_y = CHUNK_HEIGHT - 2;
|
||||
while feet_y < max_y {
|
||||
let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid()
|
||||
|| world.get_block(IVec3::new(x, feet_y + 1, z)).solid();
|
||||
if !body_blocked {
|
||||
return Vec3::new(x as f32 + 0.5, feet_y as f32, z as f32 + 0.5);
|
||||
}
|
||||
feet_y += 1;
|
||||
}
|
||||
Vec3::new(x as f32 + 0.5, (surface_y + 1) as f32, z as f32 + 0.5)
|
||||
}
|
||||
|
||||
/// Damage from a free-fall of `distance` blocks. The first 3.5 blocks
|
||||
/// are safe (jump height + slack); each block beyond costs one HP,
|
||||
/// capped at 20. NaN / infinity / negative distances all collapse to 0.
|
||||
pub fn fall_damage(distance: f32) -> u8 {
|
||||
if !distance.is_finite() || distance <= 3.5 {
|
||||
0
|
||||
} else {
|
||||
(distance - 3.5).floor().clamp(0.0, 20.0) as u8
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::Block;
|
||||
|
||||
#[test]
|
||||
fn fall_damage_zero_for_short_falls() {
|
||||
assert_eq!(fall_damage(0.0), 0);
|
||||
assert_eq!(fall_damage(2.5), 0);
|
||||
assert_eq!(fall_damage(3.5), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_damage_starts_at_one_just_past_threshold() {
|
||||
assert_eq!(fall_damage(4.5), 1);
|
||||
assert_eq!(fall_damage(5.0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_damage_caps_at_20() {
|
||||
assert_eq!(fall_damage(100.0), 20);
|
||||
assert_eq!(fall_damage(f32::MAX), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_damage_handles_nonsense() {
|
||||
assert_eq!(fall_damage(-5.0), 0);
|
||||
assert_eq!(fall_damage(f32::NAN), 0);
|
||||
assert_eq!(fall_damage(f32::INFINITY), 0);
|
||||
assert_eq!(fall_damage(f32::NEG_INFINITY), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_lands_on_natural_surface_in_pristine_world() {
|
||||
let world = World::new();
|
||||
let spawn = find_safe_spawn(&world);
|
||||
let expected = natural_surface_y(0, 0) + 1;
|
||||
assert_eq!(spawn.y as i32, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_rises_above_player_built_tower() {
|
||||
let mut world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
for y in (surface + 1)..=(surface + 10) {
|
||||
assert!(world.set_block(IVec3::new(0, y, 0), Block::Stone));
|
||||
}
|
||||
let spawn = find_safe_spawn(&world);
|
||||
assert!(spawn.y as i32 > surface + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_returns_to_natural_after_tower_is_broken() {
|
||||
let mut world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
for y in (surface + 1)..=(surface + 10) {
|
||||
world.set_block(IVec3::new(0, y, 0), Block::Stone);
|
||||
}
|
||||
for y in (surface + 1)..=(surface + 10) {
|
||||
world.set_block(IVec3::new(0, y, 0), Block::Air);
|
||||
}
|
||||
let spawn = find_safe_spawn(&world);
|
||||
assert_eq!(spawn.y as i32, surface + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_unaffected_by_remote_holes_below_surface() {
|
||||
let mut world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
for y in 0..=surface {
|
||||
world.set_block(IVec3::new(0, y, 0), Block::Air);
|
||||
}
|
||||
let spawn = find_safe_spawn(&world);
|
||||
assert_eq!(spawn.y as i32, surface + 1);
|
||||
}
|
||||
}
|
||||
1057
src/state.rs
1057
src/state.rs
File diff suppressed because it is too large
Load diff
|
|
@ -616,6 +616,11 @@
|
|||
<input id="set-dist" type="range" min="64" max="800" step="16" />
|
||||
<span class="value" id="set-dist-val"></span>
|
||||
</div>
|
||||
<div class="menu-row">
|
||||
<label for="set-tscale">Time of day speed</label>
|
||||
<input id="set-tscale" type="range" min="0" max="8" step="0.25" />
|
||||
<span class="value" id="set-tscale-val"></span>
|
||||
</div>
|
||||
<div class="menu-actions">
|
||||
<button id="menu-resume">RESUME</button>
|
||||
<button id="menu-respawn" class="secondary">Respawn</button>
|
||||
|
|
|
|||
|
|
@ -90,12 +90,15 @@ function setupMenu() {
|
|||
const fovVal = document.getElementById("set-fov-val");
|
||||
const dist = document.getElementById("set-dist");
|
||||
const distVal = document.getElementById("set-dist-val");
|
||||
const tscale = document.getElementById("set-tscale");
|
||||
const tscaleVal = document.getElementById("set-tscale-val");
|
||||
const name = document.getElementById("set-name");
|
||||
|
||||
const saved = JSON.parse(localStorage.getItem("voxel-settings") || "{}");
|
||||
sens.value = saved.sens ?? 0.005;
|
||||
fov.value = saved.fov ?? 70;
|
||||
dist.value = saved.dist ?? 240;
|
||||
tscale.value = saved.tscale ?? 1.0;
|
||||
name.value = localStorage.getItem("voxel-name") || "";
|
||||
const topName = document.getElementById("player-name");
|
||||
if (topName) topName.value = name.value;
|
||||
|
|
@ -104,17 +107,21 @@ function setupMenu() {
|
|||
const sv = parseFloat(sens.value);
|
||||
const fv = parseFloat(fov.value);
|
||||
const dv = parseFloat(dist.value);
|
||||
const tv = parseFloat(tscale.value);
|
||||
wasm.set_mouse_sens(sv);
|
||||
wasm.set_fov(fv);
|
||||
wasm.set_render_distance(dv);
|
||||
wasm.set_time_scale(tv);
|
||||
sensVal.textContent = sv.toFixed(4);
|
||||
fovVal.textContent = fv + "°";
|
||||
distVal.textContent = dv + " bl";
|
||||
localStorage.setItem("voxel-settings", JSON.stringify({ sens: sv, fov: fv, dist: dv }));
|
||||
tscaleVal.textContent = tv === 0 ? "frozen" : (tv.toFixed(2) + "×");
|
||||
localStorage.setItem("voxel-settings", JSON.stringify({ sens: sv, fov: fv, dist: dv, tscale: tv }));
|
||||
};
|
||||
sens.addEventListener("input", apply);
|
||||
fov.addEventListener("input", apply);
|
||||
dist.addEventListener("input", apply);
|
||||
tscale.addEventListener("input", apply);
|
||||
apply();
|
||||
|
||||
const pushName = () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue