Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef25e66f08 | ||
|
|
a469202959 | ||
|
|
e6f5db8a89 | ||
|
|
fdd236071e | ||
|
|
ea9d821047 | ||
|
|
05f80c81ad | ||
|
|
d07528bd0e | ||
|
|
c750321ced | ||
|
|
d107405f62 | ||
|
|
a254b96f65 | ||
|
|
5fb23cf404 | ||
|
|
213363d0be | ||
|
|
4539898c58 | ||
|
|
25f8c683bb | ||
|
|
f8d406dcf0 |
@@ -137,7 +137,7 @@ Only `manifest.json` is required. Data folders can have subfolders, with one def
|
||||
| `scripts.server` | no | Server entry. Never sent to clients. |
|
||||
| `scripts.client` | no | Client entry, sent to every player. |
|
||||
| `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. |
|
||||
| `credits` | no | A markdown file in the mod, shown on the About page. For asset licenses and thanks. |
|
||||
| `credits` | no | A markdown file in the mod, shown on the Credits screen. For asset licenses and thanks. |
|
||||
|
||||
Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by both the
|
||||
server and client entries is copied into both bundles, so **don't import secrets into shared code**. Anything the client
|
||||
@@ -195,13 +195,16 @@ program**, so the server and each client can number blocks differently. Saves an
|
||||
| ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | required | `id` | The block's id. |
|
||||
| `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. |
|
||||
| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. |
|
||||
| `alpha` | `1` | `alpha` | Opacity for transparent blocks. |
|
||||
| `render_layer` | `solid` | `render_layer` | `solid`, `cutout` (texels fully opaque or fully clear, like leaves) or `translucent` (blended, like water and glass). Non-solid blocks don't hide their neighbors' faces. |
|
||||
| `cull_same` | see meaning | `cull_same` | Hide faces between two of this block. Defaults to `true` for translucent blocks, `false` otherwise. |
|
||||
| `light_emission` | `0` | `light_emission` | Light level 0-15 it gives off, like a torch (14). |
|
||||
| `light_opacity` | see meaning | `light_opacity` | How much light passing through it loses, 0-15. Defaults to `15` (blocks all light) for solid blocks and `0` otherwise. Leaves and water use `1`. |
|
||||
| `alpha` | `1` | `alpha` | Opacity for translucent blocks. |
|
||||
| `collision` | `true` | `has_collision` | Whether entities collide with it. |
|
||||
| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. |
|
||||
| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. |
|
||||
| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. |
|
||||
| `drops` | nothing | `drop_table` | Item id given when broken. |
|
||||
| `drops` | nothing | `drop_table` | Item id dropped on the ground when broken, for players to pick up. |
|
||||
| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. |
|
||||
| `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. |
|
||||
| `replaceable` | `false` | new | Placing a block into it replaces it, like water. |
|
||||
@@ -405,6 +408,7 @@ interface Player {
|
||||
readonly inventory: Container; // 36 slots, hotbar is 0-8
|
||||
readonly selected_slot: number;
|
||||
readonly held_item: ItemStack | undefined;
|
||||
// what doesn't fit in the inventory drops at their feet
|
||||
give_item(id: string, count?: number, data?: unknown): void;
|
||||
send_message(text: string): void;
|
||||
teleport(x: number, y: number, z: number): void;
|
||||
@@ -714,6 +718,16 @@ Each chunk is generated in three passes:
|
||||
2. **Ores.** Every mod's `worldgen/ores.json`, in load order.
|
||||
3. **Features.** Every mod's registered features, in load order.
|
||||
|
||||
The world is 256 blocks tall and the sea is at y 64 (`CHUNK_HEIGHT` and `SEA_LEVEL` in `common/constants.ts`). The
|
||||
base overworld lives in `common/worldgen/` until phase 3 moves it into `mods/bworld`. It works like Minecraft 1.18+ and
|
||||
the Terralith datapack: continentalness, erosion and weirdness noises feed nested splines that give every column a
|
||||
height, jaggedness and roughness, a 3D density around that height is sampled on a coarse grid and interpolated, and
|
||||
caves are cut out of it. On top of that come Terralith-style shapes: terraced plateaus, shattered hills, river valleys
|
||||
and gorges, jagged peaks and rare sky islands. Its biome ids (`biome_at`) are the keys of `BIOMES` in
|
||||
`common/worldgen/overworld.ts`, like `bworld:yosemite_cliffs` or `bworld:skylands`. Trees
|
||||
(`common/worldgen/trees.ts`) are spread out like Poisson disk sampling, never closer than 4 blocks, and each biome sets
|
||||
how many grow and which kinds. `deno run -A tools/worldgen_preview.ts [seed]` renders it to PNG files.
|
||||
|
||||
### Terrain generators
|
||||
|
||||
A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`.
|
||||
@@ -753,7 +767,7 @@ Seeding is exact, so a generator ported from the current code produces the same
|
||||
|
||||
### Ores
|
||||
|
||||
`worldgen/ores.json` uses the same fields as the `ORES` table in `common/generation.ts`, plus the block they replace:
|
||||
`worldgen/ores.json` uses the same fields as the `BASE_ORES` table in `common/generation.ts`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -849,8 +863,8 @@ client server
|
||||
│ │
|
||||
│ ready │
|
||||
├────────────────────────────────────────►│
|
||||
│ join { id, name, players, changes, │ the player is created here, player_join fires,
|
||||
│ spawn, selected_slot } │ and their inventory follows as container messages
|
||||
│ join { id, name, players, entities, │ the player is created here, player_join fires,
|
||||
│ changes, spawn, selected_slot } │ and their inventory follows as container messages
|
||||
│◄────────────────────────────────────────┤
|
||||
```
|
||||
|
||||
@@ -875,9 +889,10 @@ where the page came from:
|
||||
|
||||
- **Page served by the game server** (the default): that server already chose every line of JavaScript on the page, so
|
||||
running its mods doesn't trust it any more than loading the page did.
|
||||
- **Page from one origin, game server on another** (`?server=`): mod code from that server runs with _the page's_
|
||||
origin, including its local storage. The client shows the server's mod list and asks before loading anything, and
|
||||
remembers the answer per server and mod hash. The server needs CORS headers on `build/mods/`.
|
||||
- **Page from one origin, game server on another** (a different server typed in the title screen, or `?server=`): mod
|
||||
code from that server runs with _the page's_ origin, including its local storage. The client shows the server's mod
|
||||
list and asks before loading anything, and remembers the answer per server and mod hash. The server needs CORS
|
||||
headers on `build/mods/`.
|
||||
- The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server.
|
||||
|
||||
**Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs
|
||||
@@ -1025,8 +1040,8 @@ anything the base game does.
|
||||
| Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` |
|
||||
| Furnace (smelting, fuel, its screen) | `server/game/blocks.ts` | component `bworld:furnace` |
|
||||
| Watering can's starting water | `common/items/watering_can.ts` | item component `bworld:watering_can`, params `{ max_water }` |
|
||||
| Terrain, biomes and trees | `common/generation.ts` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` |
|
||||
| Ore table | `common/generation.ts` | `worldgen/ores.json`, if it generates identically (see phase 3) |
|
||||
| Terrain and biomes | `common/worldgen/` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` |
|
||||
| Ore table (`BASE_ORES`) | `common/generation.ts` | `worldgen/ores.json`, unchanged |
|
||||
| Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` |
|
||||
|
||||
Not moved:
|
||||
@@ -1096,27 +1111,24 @@ loading mods (the base game and the template), their scripts and worldgen, saves
|
||||
|
||||
**Phase 3: world generation** (after step 9).
|
||||
|
||||
- Port `generate_chunk` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a
|
||||
straight port: the seeding rules in [Terrain generators](#terrain-generators) were chosen so the same noise names give
|
||||
the same values.
|
||||
- Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be
|
||||
overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and
|
||||
the terrain would change.
|
||||
- Try moving the ores to `ores.json` with `"replaces": "bworld:stone"`. The current code only places ores in stone, so
|
||||
this should be identical, but only the golden test can say so. If it isn't, the ores stay inside the terrain
|
||||
generator.
|
||||
- Port `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a
|
||||
straight port: it only uses named noises, which are exactly `noise_2d` / `noise_3d` in
|
||||
[Terrain generators](#terrain-generators).
|
||||
- Move `BASE_ORES` to `mods/bworld/worldgen/ores.json`. They already run through the same ore pass as mods' ores, in
|
||||
the same order, so the result is identical.
|
||||
- Remove the content from `common/generation.ts`, leaving the passes and noise helpers.
|
||||
- Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged.
|
||||
|
||||
**Phase 4: engine cleanup.**
|
||||
|
||||
- Replace the hardcoded water checks in `client/systems/player_controls.ts` and `server/game/game_server.ts` with the
|
||||
`replaceable` block field.
|
||||
- Replace the hardcoded water checks in `server/game/game_server.ts` with the `replaceable` block field, like
|
||||
`client/game_mode.ts` already does.
|
||||
- `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have
|
||||
the same name.
|
||||
- Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means
|
||||
content.
|
||||
- The About page shows the engine's `assets/ASSETS.md` plus every loaded mod's credits.
|
||||
- The Credits screen (title screen and pause menu) shows the engine's `assets/ASSETS.md` plus every loaded mod's credits.
|
||||
_Done._ Credits are published next to the mod's data and checked by hash like everything else.
|
||||
- The server refuses to start without a terrain generator, naming the mods that could provide one.
|
||||
|
||||
### Done when
|
||||
@@ -1176,5 +1188,6 @@ Moving the base game into `mods/bworld` happens alongside steps 4–9. See
|
||||
components to an existing block (for example to `bworld:grass`) is a possible middle ground.
|
||||
- **Screen scaling.** The GUI currently works in raw canvas pixels (`SLOT_SIZE` is 54). `Graphics` should probably use a
|
||||
UI scale the player can change, with screens laid out in scaled units.
|
||||
- **Entities.** The game's only entity is the player, so mob mods are out of scope until the ECS has more entity types.
|
||||
- **Entities.** The only entities are players (`client/entity/`), so mob mods are out of scope until there are more
|
||||
entity types.
|
||||
- **Hot reload.** Restarting the server and reconnecting is the version 1 answer.
|
||||
|
||||
@@ -180,7 +180,12 @@ async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
|
||||
const index: ServerModIndex = { atlas, mods: [] };
|
||||
|
||||
for (const mod of mods) {
|
||||
const manifest = mod.manifest as { name: string; version: string; scripts?: Record<string, string> };
|
||||
const manifest = mod.manifest as {
|
||||
name: string;
|
||||
version: string;
|
||||
scripts?: Record<string, string>;
|
||||
credits?: string;
|
||||
};
|
||||
const scripts = manifest.scripts ?? {};
|
||||
|
||||
const data: ModData = {
|
||||
@@ -196,7 +201,9 @@ async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
|
||||
: undefined;
|
||||
const server = scripts.server ? await bundle_script(`${mod.dir}/${scripts.server}`, "deno") : undefined;
|
||||
|
||||
const hash = await short_hash([data_json, client ?? "", worldgen ?? ""]);
|
||||
const credits = manifest.credits ? Deno.readTextFileSync(`${mod.dir}/${manifest.credits}`) : undefined;
|
||||
|
||||
const hash = await short_hash([data_json, client ?? "", worldgen ?? "", credits ?? ""]);
|
||||
const public_dir = `mods/${mod.id}/${hash}`;
|
||||
Deno.mkdirSync(`${BUILD_FOLDER}/${public_dir}`, { recursive: true });
|
||||
|
||||
@@ -219,6 +226,11 @@ async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
|
||||
listing.sha256.worldgen = await sha256(worldgen);
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
|
||||
}
|
||||
if (credits !== undefined) {
|
||||
listing.credits = `${public_dir}/credits.md`;
|
||||
listing.sha256.credits = await sha256(credits);
|
||||
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.credits}`, credits);
|
||||
}
|
||||
|
||||
const entry: ServerModIndex["mods"][number] = { listing };
|
||||
if (server) {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { marked } from "marked";
|
||||
import { AssetManager } from "./assets.ts";
|
||||
|
||||
export async function open_about() {
|
||||
const popup = self.open(
|
||||
"",
|
||||
"popupWindow",
|
||||
"width=500,height=400",
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
alert("oh no");
|
||||
return;
|
||||
}
|
||||
|
||||
popup.document.body.innerHTML = await marked.parse(AssetManager.instance.get("bworld:assets_text"));
|
||||
popup.document.head.innerHTML = `<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.7;
|
||||
color: #e5e7eb;
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
code {
|
||||
background: #1f2937;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
pre {
|
||||
background: #0f172a;
|
||||
color: #f9fafb;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
color: #6b7280;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
th {
|
||||
background: #f9fafb;
|
||||
text-align: left;
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Entity } from "./entity/entity.ts";
|
||||
|
||||
// where the world is drawn from. like minecraft's Camera it isn't an entity, it's moved to one's eyes every frame
|
||||
export class Camera {
|
||||
x = 0;
|
||||
y = 0;
|
||||
z = 3;
|
||||
|
||||
pitch = 0;
|
||||
yaw = 0;
|
||||
roll = 0;
|
||||
|
||||
fov = Math.PI / 3;
|
||||
near = 0.1;
|
||||
far = 1000;
|
||||
|
||||
setup(entity: Entity, partial_tick: number) {
|
||||
const { x, y, z } = entity.render_position(partial_tick);
|
||||
this.x = x;
|
||||
this.y = y + entity.eye_height;
|
||||
this.z = z;
|
||||
this.yaw = entity.yaw;
|
||||
this.pitch = entity.pitch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { ClientLevel } from "./level/client_level.ts";
|
||||
import type { BlockHitResult } from "./level/client_level.ts";
|
||||
import { LocalPlayer } from "./entity/local_player.ts";
|
||||
import { RemotePlayer } from "./entity/remote_player.ts";
|
||||
import { ItemEntity } from "./entity/item_entity.ts";
|
||||
import { Camera } from "./camera.ts";
|
||||
import { Options } from "./options.ts";
|
||||
import { MultiPlayerGameMode } from "./game_mode.ts";
|
||||
import { ClientPacketListener } from "./packet_listener.ts";
|
||||
import { GameRenderer } from "./rendering/game_renderer.ts";
|
||||
import { ChatComponent } from "./gui/chat_component.ts";
|
||||
import { GuiInventoryScreen, GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { GuiPlayerInventory } from "./gui/gui_player_inventory.ts";
|
||||
import { GuiChat } from "./gui/gui_chat.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { Connection } from "./network.ts";
|
||||
import { PauseScreen } from "./gui/pause_screen.ts";
|
||||
import { back_to_title } from "./gui/title_screen.ts";
|
||||
import { TICK_DELTA } from "$/common/constants.ts";
|
||||
|
||||
// after a long stall (a hidden tab, a debugger) the game skips ahead instead of running every missed tick at once
|
||||
const MAX_TICKS_PER_FRAME = 10;
|
||||
|
||||
// the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer.
|
||||
// the game runs in fixed ticks (TICKS_PER_SECOND), input and drawing happen every frame
|
||||
export class Client {
|
||||
connection: Connection;
|
||||
options = new Options();
|
||||
level: ClientLevel;
|
||||
player: LocalPlayer;
|
||||
camera = new Camera();
|
||||
game_mode: MultiPlayerGameMode;
|
||||
packet_listener: ClientPacketListener;
|
||||
game_renderer = new GameRenderer();
|
||||
chat = new ChatComponent();
|
||||
|
||||
// open screens, the last one is on top and gets the input
|
||||
screens: GuiScreen[] = [];
|
||||
// what the player is looking at, updated every frame
|
||||
hit_result: BlockHitResult | undefined;
|
||||
debugging = false;
|
||||
|
||||
// seconds of game time not ticked yet
|
||||
#pending_time = 0;
|
||||
// whether the attack button is held on a block, breaking it advances every tick
|
||||
#attacking = false;
|
||||
#stopped = false;
|
||||
// called once when the connection drops, with why
|
||||
#on_disconnect: (message: string) => void;
|
||||
|
||||
constructor(connection: Connection, on_disconnect: (message: string) => void) {
|
||||
this.connection = connection;
|
||||
this.#on_disconnect = on_disconnect;
|
||||
|
||||
this.level = new ClientLevel(connection.seed);
|
||||
for (const [x, y, z, id, state] of connection.initial_changes) {
|
||||
this.level.record_change(x, y, z, id, state);
|
||||
}
|
||||
|
||||
const spawn = connection.spawn;
|
||||
this.player = new LocalPlayer(this, connection.id, connection.name);
|
||||
this.player.set_position(spawn.x, spawn.y, spawn.z);
|
||||
this.player.yaw = spawn.yaw;
|
||||
this.player.pitch = spawn.pitch;
|
||||
this.player.inventories.hotbar_selected = connection.selected_slot;
|
||||
this.level.add_entity(this.player);
|
||||
for (const info of connection.initial_players) {
|
||||
this.level.add_entity(new RemotePlayer(this.level, info));
|
||||
}
|
||||
for (const info of connection.initial_entities) {
|
||||
this.level.add_entity(new ItemEntity(this.level, info));
|
||||
}
|
||||
|
||||
this.game_mode = new MultiPlayerGameMode(this);
|
||||
this.packet_listener = new ClientPacketListener(this);
|
||||
}
|
||||
|
||||
get screen(): GuiScreen | undefined {
|
||||
return this.screens.at(-1);
|
||||
}
|
||||
|
||||
push_screen(screen: GuiScreen) {
|
||||
this.screens.push(screen);
|
||||
}
|
||||
|
||||
pop_screen() {
|
||||
this.screens.pop()?.on_close();
|
||||
}
|
||||
|
||||
// leaving on purpose, from the pause screen
|
||||
disconnect() {
|
||||
this.#stopped = true;
|
||||
this.connection.close();
|
||||
back_to_title();
|
||||
}
|
||||
|
||||
#stop() {
|
||||
this.#stopped = true;
|
||||
this.level.dispose();
|
||||
InputManager.set_mouse_grabbed(false);
|
||||
}
|
||||
|
||||
// one frame: input, as many ticks as are due, then drawing between the last tick and the next
|
||||
run_frame(delta: number) {
|
||||
if (this.#stopped) {
|
||||
return;
|
||||
}
|
||||
this.screen?.on_tick(delta);
|
||||
this.#handle_keybinds();
|
||||
|
||||
this.#pending_time += delta;
|
||||
let ticks = 0;
|
||||
while (this.#pending_time >= TICK_DELTA && ticks < MAX_TICKS_PER_FRAME) {
|
||||
this.tick();
|
||||
if (this.#stopped) {
|
||||
return;
|
||||
}
|
||||
this.#pending_time -= TICK_DELTA;
|
||||
ticks += 1;
|
||||
}
|
||||
if (ticks === MAX_TICKS_PER_FRAME) {
|
||||
this.#pending_time = Math.min(this.#pending_time, TICK_DELTA);
|
||||
}
|
||||
|
||||
this.game_renderer.render(this, this.#pending_time / TICK_DELTA);
|
||||
}
|
||||
|
||||
// one step of the game: what the server sent, breaking, chunk loading, then every entity
|
||||
tick() {
|
||||
this.packet_listener.handle_packets();
|
||||
if (this.connection.closed) {
|
||||
this.#stop();
|
||||
this.#on_disconnect("Lost connection to the server");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#attacking && this.hit_result) {
|
||||
this.game_mode.continue_destroy_block(this.hit_result);
|
||||
}
|
||||
|
||||
this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance);
|
||||
this.level.tick();
|
||||
}
|
||||
|
||||
#handle_keybinds() {
|
||||
const options = this.options;
|
||||
const player = this.player;
|
||||
|
||||
if (InputManager.is_key_pressed(options.key_inventory)) {
|
||||
if (!this.screen) {
|
||||
this.push_screen(new GuiPlayerInventory(player.inventories, (m) => this.connection.send(m)));
|
||||
} else if (this.screen instanceof GuiInventoryScreen) {
|
||||
this.pop_screen();
|
||||
}
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_drop)) {
|
||||
this.#handle_drop();
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
|
||||
this.push_screen(new GuiChat(this));
|
||||
}
|
||||
// browsers eat the escape that lets go of the mouse, so losing the mouse pauses too
|
||||
const lost_mouse = InputManager.take_lost_pointer_lock();
|
||||
if (InputManager.is_key_pressed("Escape")) {
|
||||
if (this.screen) {
|
||||
this.pop_screen();
|
||||
} else {
|
||||
this.push_screen(new PauseScreen(this));
|
||||
}
|
||||
} else if (lost_mouse && !this.screen) {
|
||||
this.push_screen(new PauseScreen(this));
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_debug)) {
|
||||
this.debugging = !this.debugging;
|
||||
}
|
||||
if (InputManager.is_key_pressed(options.key_fullscreen)) {
|
||||
InputManager.toggle_fullscreen();
|
||||
}
|
||||
|
||||
if (InputManager.is_mouse_grabbed()) {
|
||||
const mouse = InputManager.get_mouse_delta();
|
||||
player.turn(mouse.x, mouse.y);
|
||||
}
|
||||
InputManager.set_mouse_grabbed(!this.screen);
|
||||
|
||||
this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch);
|
||||
this.#handle_block_interaction();
|
||||
|
||||
if (!this.screen) {
|
||||
this.#handle_hotbar();
|
||||
}
|
||||
}
|
||||
|
||||
// the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor)
|
||||
#handle_drop() {
|
||||
const all = InputManager.is_key_down("ControlLeft") || InputManager.is_key_down("ControlRight");
|
||||
const inventories = this.player.inventories;
|
||||
const screen = this.screen;
|
||||
if (!screen) {
|
||||
this.game_mode.drop_item(inventories.inventory, "inventory", inventories.hotbar_selected, all);
|
||||
return;
|
||||
}
|
||||
const slot = screen instanceof GuiInventoryScreen ? screen.hovering : undefined;
|
||||
const container = slot && screen instanceof GuiInventoryScreen
|
||||
? screen.get_container(slot.container)
|
||||
: undefined;
|
||||
if (slot && container && !slot.output && !inventories.cursor.item) {
|
||||
this.game_mode.drop_item(container, slot.container, slot.index, all);
|
||||
}
|
||||
}
|
||||
|
||||
// clicks happen right away, holding to break advances in tick()
|
||||
#handle_block_interaction() {
|
||||
const hit = this.hit_result;
|
||||
const attacking = !this.screen && InputManager.is_mouse_down(0);
|
||||
this.#attacking = attacking;
|
||||
|
||||
if (!attacking || !hit) {
|
||||
this.game_mode.stop_destroy_block();
|
||||
}
|
||||
if (this.screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
if (InputManager.is_mouse_pressed(0)) {
|
||||
this.game_mode.start_destroy_block(hit);
|
||||
}
|
||||
if (!attacking && InputManager.is_mouse_pressed(2)) {
|
||||
this.game_mode.use_item_on(hit);
|
||||
}
|
||||
} else if (InputManager.is_mouse_pressed(2)) {
|
||||
this.game_mode.use_item();
|
||||
}
|
||||
}
|
||||
|
||||
#handle_hotbar() {
|
||||
const inventories = this.player.inventories;
|
||||
const previous = inventories.hotbar_selected;
|
||||
|
||||
const scroll = InputManager.get_wheel_delta();
|
||||
if (scroll > 0) {
|
||||
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
|
||||
} else if (scroll < 0) {
|
||||
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
|
||||
}
|
||||
|
||||
const pressed = this.options.key_hotbar.findIndex((key) => InputManager.is_key_pressed(key));
|
||||
if (pressed !== -1) {
|
||||
inventories.hotbar_selected = pressed;
|
||||
}
|
||||
|
||||
if (inventories.hotbar_selected !== previous) {
|
||||
this.connection.send({ type: "select_slot", slot: inventories.hotbar_selected });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { World } from "$/common/ecs/mod.ts";
|
||||
import { MovementSystem } from "$/common/systems/movement_system.ts";
|
||||
import { RenderSystem } from "$/client/systems/render_system.ts";
|
||||
import { PlayerControlsSystem } from "$/client/systems/player_controls.ts";
|
||||
import { DebugSystem } from "$/client/systems/debug_system.ts";
|
||||
import { UIInteractionSystem } from "$/client/systems/ui_interaction_system.ts";
|
||||
import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
|
||||
import { create_main_menu } from "./main_menu.ts";
|
||||
import { start_game } from "./game.ts";
|
||||
import { canvas, resize_canvas } from "./renderer/mod.ts";
|
||||
import { Dimension } from "./components/dimension.ts";
|
||||
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
|
||||
import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
|
||||
import { CollisionSystem } from "./systems/collision_system.ts";
|
||||
import { NetworkSystem } from "./systems/network_system.ts";
|
||||
import { Connection } from "./network.ts";
|
||||
|
||||
export interface ChatLine {
|
||||
text: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export class ClientWorld extends World {
|
||||
paused = false;
|
||||
debugging = false;
|
||||
|
||||
dimension!: Dimension;
|
||||
|
||||
connection: Connection;
|
||||
chat_log: ChatLine[] = [];
|
||||
|
||||
constructor(connection: Connection) {
|
||||
super("game");
|
||||
this.connection = connection;
|
||||
|
||||
this.add_state("main_menu");
|
||||
this.add_state("paused");
|
||||
this.add_state("game");
|
||||
|
||||
self.addEventListener("resize", resize_canvas);
|
||||
resize_canvas();
|
||||
|
||||
canvas.addEventListener("contextmenu", function (event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
start_game(this);
|
||||
|
||||
// Logic systems
|
||||
this.add_system(new UIInteractionSystem(), "main_menu");
|
||||
this.add_system(new UIInteractionSystem(), "paused");
|
||||
this.add_system(new NetworkSystem(), "game");
|
||||
this.add_system(new GuiTickSystem(), "game");
|
||||
this.add_system(new PlayerControlsSystem(), "game");
|
||||
this.add_system(new WorldGenerationSystem(), "game");
|
||||
this.add_system(new CollisionSystem(), "game");
|
||||
this.add_system(new MovementSystem(), "game");
|
||||
|
||||
// render systems
|
||||
this.add_system(new RenderSystem(), "game");
|
||||
this.add_system(new GuiRenderSystem(), "game");
|
||||
this.add_system(new DebugSystem(), "game");
|
||||
this.add_system(new UIRenderSystem(), "main_menu");
|
||||
this.add_system(new UIRenderSystem(), "paused");
|
||||
}
|
||||
|
||||
add_chat(text: string) {
|
||||
this.chat_log.push({ text, time: performance.now() });
|
||||
if (this.chat_log.length > 100) {
|
||||
this.chat_log.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
|
||||
export class Camera extends Component {
|
||||
x = 0;
|
||||
y = 0;
|
||||
z = 3;
|
||||
|
||||
pitch = 0;
|
||||
yaw = 0;
|
||||
roll = 0;
|
||||
|
||||
fov = Math.PI / 3;
|
||||
near = 0.1;
|
||||
far = 1000;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
|
||||
export class ClickableSprite extends Component {
|
||||
clicked = false;
|
||||
button: number;
|
||||
access_range: number;
|
||||
|
||||
constructor(button: number = 0, access_range = 2) {
|
||||
super();
|
||||
this.button = button;
|
||||
this.access_range = access_range;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
|
||||
export class CollisionCuboid extends Component {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
gravity: number;
|
||||
|
||||
colliding_x: number = 0;
|
||||
colliding_y: number = 0;
|
||||
colliding_z: number = 0;
|
||||
|
||||
constructor(width: number, height: number, depth: number, gravity = -15.8) {
|
||||
super();
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.depth = depth;
|
||||
this.gravity = gravity;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
import { KeyCode } from "$/client/input_manager.ts";
|
||||
|
||||
export class PlayerControls extends Component {
|
||||
move_speed = 4;
|
||||
jump_force = 6.7;
|
||||
|
||||
// Keys
|
||||
move_forward: KeyCode = "KeyW";
|
||||
move_backwards: KeyCode = "KeyS";
|
||||
move_left: KeyCode = "KeyA";
|
||||
move_right: KeyCode = "KeyD";
|
||||
sprint_key: KeyCode = "ShiftLeft";
|
||||
|
||||
hotbar_1: KeyCode = "Digit1";
|
||||
hotbar_2: KeyCode = "Digit2";
|
||||
hotbar_3: KeyCode = "Digit3";
|
||||
hotbar_4: KeyCode = "Digit4";
|
||||
hotbar_5: KeyCode = "Digit5";
|
||||
hotbar_6: KeyCode = "Digit6";
|
||||
hotbar_7: KeyCode = "Digit7";
|
||||
hotbar_8: KeyCode = "Digit8";
|
||||
hotbar_9: KeyCode = "Digit9";
|
||||
|
||||
open_inventory: KeyCode = "KeyE";
|
||||
open_chat: KeyCode = "KeyT";
|
||||
|
||||
open_debug: KeyCode = "F3";
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { Texture } from "../renderer/mod.ts";
|
||||
|
||||
export class Sprite extends Component {
|
||||
image: Texture;
|
||||
width: number;
|
||||
height: number;
|
||||
source_x: number;
|
||||
source_y: number;
|
||||
source_width: number;
|
||||
source_height: number;
|
||||
flip_x = false;
|
||||
flip_y = false;
|
||||
|
||||
constructor(
|
||||
image: Texture | string,
|
||||
width: number,
|
||||
height: number,
|
||||
source_x = 0,
|
||||
source_y = 0,
|
||||
source_width = width,
|
||||
source_height = height,
|
||||
) {
|
||||
super();
|
||||
if (typeof image === "string") {
|
||||
this.image = AssetManager.instance.get(image);
|
||||
} else {
|
||||
this.image = image;
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.source_x = source_x;
|
||||
this.source_y = source_y;
|
||||
this.source_width = source_width;
|
||||
this.source_height = source_height;
|
||||
}
|
||||
}
|
||||
|
||||
interface AnimatedSpritePiece {
|
||||
source_x: number[];
|
||||
source_y: number[];
|
||||
source_width: number;
|
||||
source_height: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class AnimatedSprite extends Component {
|
||||
image: Texture;
|
||||
width: number;
|
||||
height: number;
|
||||
flip_x = false;
|
||||
flip_y = false;
|
||||
|
||||
current_state: string;
|
||||
states: Record<string, AnimatedSpritePiece>;
|
||||
timer = 0;
|
||||
animation_frame = 0;
|
||||
|
||||
constructor(
|
||||
image: Texture | string,
|
||||
width: number,
|
||||
height: number,
|
||||
states: Record<string, AnimatedSpritePiece>,
|
||||
initial_state: string,
|
||||
) {
|
||||
super();
|
||||
if (typeof image === "string") {
|
||||
this.image = AssetManager.instance.get(image);
|
||||
} else {
|
||||
this.image = image;
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
this.states = states;
|
||||
this.current_state = initial_state;
|
||||
}
|
||||
|
||||
set_state(state: string) {
|
||||
if (this.current_state !== state) {
|
||||
this.current_state = state;
|
||||
this.timer = 0;
|
||||
this.animation_frame = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
|
||||
export class UIButton extends Component {
|
||||
text: string;
|
||||
width: number;
|
||||
height: number;
|
||||
on_click: () => void;
|
||||
|
||||
hovered = false;
|
||||
|
||||
constructor(text: string, width: number, height: number, on_click: () => void) {
|
||||
super();
|
||||
this.text = text;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.on_click = on_click;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { move_body } from "$/common/physics.ts";
|
||||
import type { ClientLevel } from "../level/client_level.ts";
|
||||
|
||||
// anything that exists in the level and moves, like minecraft's Entity. position is the middle of its feet.
|
||||
// it's simulated in fixed ticks (common/constants.ts), frames draw it between its last two positions
|
||||
export abstract class Entity {
|
||||
id: string;
|
||||
level: ClientLevel;
|
||||
|
||||
x = 0;
|
||||
y = 0;
|
||||
z = 0;
|
||||
// where it was at the start of the tick, what frames interpolate from
|
||||
prev_x = 0;
|
||||
prev_y = 0;
|
||||
prev_z = 0;
|
||||
// blocks per second
|
||||
vx = 0;
|
||||
vy = 0;
|
||||
vz = 0;
|
||||
yaw = 0;
|
||||
pitch = 0;
|
||||
|
||||
// the collision box, width is used for both x and z
|
||||
width: number;
|
||||
height: number;
|
||||
eye_height: number;
|
||||
// blocks per second squared
|
||||
gravity = -32;
|
||||
// what's left of its speed after each tick in the air, like minecraft's 0.98. it caps falling speed
|
||||
// at 49 * -gravity * TICK_DELTA, 78 blocks per second with minecraft's gravity
|
||||
drag = 0.98;
|
||||
|
||||
// which way it hit something on each axis in the last move: 1 or -1, 0 for nothing.
|
||||
// 1 on y means it's standing on something
|
||||
colliding_x = 0;
|
||||
colliding_y = 0;
|
||||
colliding_z = 0;
|
||||
|
||||
constructor(level: ClientLevel, id: string, width: number, height: number, eye_height: number) {
|
||||
this.level = level;
|
||||
this.id = id;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.eye_height = eye_height;
|
||||
}
|
||||
|
||||
get on_ground() {
|
||||
return this.colliding_y === 1;
|
||||
}
|
||||
|
||||
// jumps there, without interpolating from where it was
|
||||
set_position(x: number, y: number, z: number) {
|
||||
this.x = this.prev_x = x;
|
||||
this.y = this.prev_y = y;
|
||||
this.z = this.prev_z = z;
|
||||
}
|
||||
|
||||
save_previous_position() {
|
||||
this.prev_x = this.x;
|
||||
this.prev_y = this.y;
|
||||
this.prev_z = this.z;
|
||||
}
|
||||
|
||||
// where to draw it, partial_tick is how far the frame is between the last tick and the next
|
||||
render_position(partial_tick: number) {
|
||||
return {
|
||||
x: this.prev_x + (this.x - this.prev_x) * partial_tick,
|
||||
y: this.prev_y + (this.y - this.prev_y) * partial_tick,
|
||||
z: this.prev_z + (this.z - this.prev_z) * partial_tick,
|
||||
};
|
||||
}
|
||||
|
||||
// one step of the game, TICK_DELTA seconds
|
||||
abstract tick(): void;
|
||||
|
||||
// falls and moves by its velocity for one tick (see move_body), then slows down from drag
|
||||
move() {
|
||||
const collisions = move_body(this, this.gravity, (x, y, z) => this.level.has_collision(x, y, z));
|
||||
this.colliding_x = collisions.x;
|
||||
this.colliding_y = collisions.y;
|
||||
this.colliding_z = collisions.z;
|
||||
this.vx *= this.drag;
|
||||
this.vy *= this.drag;
|
||||
this.vz *= this.drag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ItemStack } from "$/common/inventory.ts";
|
||||
import type { EntityInfo } from "$/common/protocol.ts";
|
||||
import type { ClientLevel } from "../level/client_level.ts";
|
||||
import { Entity } from "./entity.ts";
|
||||
|
||||
// how many ticks a server position takes to reach, like minecraft's lerpTo
|
||||
const LERP_TICKS = 3;
|
||||
// how many ticks it takes to fly into whoever picked it up
|
||||
const PICKUP_TICKS = 3;
|
||||
|
||||
// an item lying on the ground. the server simulates it, this only moves where it's told and spins
|
||||
export class ItemEntity extends Entity {
|
||||
item: ItemStack;
|
||||
// ticks since it showed up, for spinning and bobbing
|
||||
age = 0;
|
||||
// so items dropped together don't spin in step
|
||||
readonly bob_offset = Math.random() * Math.PI * 2;
|
||||
|
||||
#target_x: number;
|
||||
#target_y: number;
|
||||
#target_z: number;
|
||||
#lerp_ticks = 0;
|
||||
|
||||
// who's picking it up, and for how many ticks it has been flying to them
|
||||
#picked_up_by: Entity | undefined;
|
||||
#pickup_age = 0;
|
||||
|
||||
constructor(level: ClientLevel, info: EntityInfo) {
|
||||
super(level, info.id, 0.25, 0.25, 0.125);
|
||||
this.item = ItemStack.from_data(info.item);
|
||||
this.set_position(info.x, info.y, info.z);
|
||||
this.#target_x = info.x;
|
||||
this.#target_y = info.y;
|
||||
this.#target_z = info.z;
|
||||
}
|
||||
|
||||
lerp_to(x: number, y: number, z: number) {
|
||||
this.#target_x = x;
|
||||
this.#target_y = y;
|
||||
this.#target_z = z;
|
||||
this.#lerp_ticks = LERP_TICKS;
|
||||
}
|
||||
|
||||
// it already went into their inventory on the server, this is only the animation
|
||||
pick_up(by: Entity) {
|
||||
this.#picked_up_by = by;
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.age += 1;
|
||||
|
||||
if (this.#picked_up_by) {
|
||||
this.#pickup_age += 1;
|
||||
if (this.#pickup_age >= PICKUP_TICKS) {
|
||||
this.level.remove_entity(this.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#lerp_ticks > 0) {
|
||||
this.x += (this.#target_x - this.x) / this.#lerp_ticks;
|
||||
this.y += (this.#target_y - this.y) / this.#lerp_ticks;
|
||||
this.z += (this.#target_z - this.z) / this.#lerp_ticks;
|
||||
this.#lerp_ticks -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// while being picked up it speeds towards the middle of whoever took it, like minecraft's ItemPickupParticle
|
||||
override render_position(partial_tick: number) {
|
||||
const position = super.render_position(partial_tick);
|
||||
const by = this.#picked_up_by;
|
||||
if (!by) {
|
||||
return position;
|
||||
}
|
||||
const target = by.render_position(partial_tick);
|
||||
const t = Math.min(1, (this.#pickup_age + partial_tick) / PICKUP_TICKS) ** 2;
|
||||
return {
|
||||
x: position.x + (target.x - position.x) * t,
|
||||
y: position.y + (target.y + 0.5 - position.y) * t,
|
||||
z: position.z + (target.z - position.z) * t,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Client } from "../client.ts";
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
import { Player } from "./player.ts";
|
||||
|
||||
// the position goes to the server every other tick
|
||||
const SEND_POSITION_TICKS = 2;
|
||||
|
||||
// the player this client controls, like minecraft's LocalPlayer
|
||||
export class LocalPlayer extends Player {
|
||||
client: Client;
|
||||
inventories = new ClientInventories();
|
||||
|
||||
move_speed = 4;
|
||||
// blocks per second, peaks about 1.25 blocks up with the entity's gravity and drag
|
||||
jump_force = 9.23;
|
||||
|
||||
#ticks_since_sent = 0;
|
||||
|
||||
constructor(client: Client, id: string, name: string) {
|
||||
super(client.level, id, name);
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.client.screen) {
|
||||
this.#apply_input();
|
||||
}
|
||||
this.move();
|
||||
this.#send_position();
|
||||
}
|
||||
|
||||
// turns with the mouse every frame, not every tick, like minecraft's MouseHandler.turnPlayer
|
||||
turn(mouse_dx: number, mouse_dy: number) {
|
||||
this.yaw += -mouse_dx * 0.001;
|
||||
this.pitch += -mouse_dy * 0.001;
|
||||
|
||||
const limit = Math.PI / 2 - 0.01;
|
||||
this.pitch = Math.max(-limit, Math.min(limit, this.pitch));
|
||||
}
|
||||
|
||||
// walking relative to where it's looking
|
||||
#apply_input() {
|
||||
const options = this.client.options;
|
||||
let input_x = 0;
|
||||
let input_z = 0;
|
||||
|
||||
if (InputManager.is_key_down(options.key_left)) {
|
||||
input_x -= 1;
|
||||
}
|
||||
if (InputManager.is_key_down(options.key_right)) {
|
||||
input_x += 1;
|
||||
}
|
||||
if (InputManager.is_key_down(options.key_forward)) {
|
||||
input_z -= 1;
|
||||
}
|
||||
if (InputManager.is_key_down(options.key_back)) {
|
||||
input_z += 1;
|
||||
}
|
||||
|
||||
const size = Math.hypot(input_x, input_z);
|
||||
if (size > 0) {
|
||||
input_x /= size;
|
||||
input_z /= size;
|
||||
}
|
||||
|
||||
const sin = Math.sin(this.yaw);
|
||||
const cos = Math.cos(this.yaw);
|
||||
const speed = this.move_speed * (InputManager.is_key_down(options.key_sprint) ? 1.75 : 1);
|
||||
|
||||
this.vx = (sin * input_z + cos * input_x) * speed;
|
||||
this.vz = (cos * input_z - sin * input_x) * speed;
|
||||
|
||||
if (InputManager.is_key_down(options.key_jump) && this.on_ground) {
|
||||
this.vy += this.jump_force;
|
||||
}
|
||||
}
|
||||
|
||||
#send_position() {
|
||||
this.#ticks_since_sent += 1;
|
||||
if (this.#ticks_since_sent < SEND_POSITION_TICKS) {
|
||||
return;
|
||||
}
|
||||
this.#ticks_since_sent = 0;
|
||||
this.client.connection.send({
|
||||
type: "move",
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
z: this.z,
|
||||
yaw: this.yaw,
|
||||
pitch: this.pitch,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PLAYER_EYE_HEIGHT, PLAYER_HEIGHT, PLAYER_WIDTH } from "$/common/constants.ts";
|
||||
import type { ClientLevel } from "../level/client_level.ts";
|
||||
import { Entity } from "./entity.ts";
|
||||
|
||||
export abstract class Player extends Entity {
|
||||
name: string;
|
||||
|
||||
constructor(level: ClientLevel, id: string, name: string) {
|
||||
super(level, id, PLAYER_WIDTH, PLAYER_HEIGHT, PLAYER_EYE_HEIGHT);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PlayerInfo } from "$/common/protocol.ts";
|
||||
import { TICK_DELTA } from "$/common/constants.ts";
|
||||
import type { ClientLevel } from "../level/client_level.ts";
|
||||
import { Player } from "./player.ts";
|
||||
|
||||
const SMOOTHING = 12;
|
||||
|
||||
// another player on the server, it moves where the server says instead of simulating anything
|
||||
export class RemotePlayer extends Player {
|
||||
color: [number, number, number];
|
||||
|
||||
// where the server last said it is, the drawn position eases towards it so movement isn't choppy
|
||||
target_x: number;
|
||||
target_y: number;
|
||||
target_z: number;
|
||||
|
||||
constructor(level: ClientLevel, info: PlayerInfo) {
|
||||
super(level, info.id, info.name);
|
||||
this.set_position(info.x, info.y, info.z);
|
||||
this.target_x = info.x;
|
||||
this.target_y = info.y;
|
||||
this.target_z = info.z;
|
||||
this.yaw = info.yaw;
|
||||
this.pitch = info.pitch;
|
||||
this.color = color_from_name(info.name);
|
||||
}
|
||||
|
||||
lerp_to(x: number, y: number, z: number, yaw: number, pitch: number) {
|
||||
this.target_x = x;
|
||||
this.target_y = y;
|
||||
this.target_z = z;
|
||||
this.yaw = yaw;
|
||||
this.pitch = pitch;
|
||||
}
|
||||
|
||||
tick() {
|
||||
const t = Math.min(1, TICK_DELTA * SMOOTHING);
|
||||
this.x += (this.target_x - this.x) * t;
|
||||
this.y += (this.target_y - this.y) * t;
|
||||
this.z += (this.target_z - this.z) * t;
|
||||
}
|
||||
}
|
||||
|
||||
function color_from_name(name: string): [number, number, number] {
|
||||
let hash = 0;
|
||||
for (const ch of name) {
|
||||
hash = (hash * 31 + ch.charCodeAt(0)) | 0;
|
||||
}
|
||||
const hue = ((hash % 360) + 360) % 360;
|
||||
// hsl with s=0.6 l=0.6 to rgb
|
||||
const c = 0.48;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = 0.36;
|
||||
const [r, g, b] = hue < 60
|
||||
? [c, x, 0]
|
||||
: hue < 120
|
||||
? [x, c, 0]
|
||||
: hue < 180
|
||||
? [0, c, x]
|
||||
: hue < 240
|
||||
? [0, x, c]
|
||||
: hue < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x];
|
||||
return [r + m, g + m, b + m];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Entity } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { Dimension } from "./components/dimension.ts";
|
||||
import { create_player } from "./player.ts";
|
||||
import { UIButton } from "./components/ui_components.ts";
|
||||
import { open_about } from "./about.ts";
|
||||
import { canvas } from "./renderer/mod.ts";
|
||||
|
||||
export function start_game(world: ClientWorld) {
|
||||
world.state = "game";
|
||||
world.clear_entities();
|
||||
|
||||
const dimension = new Entity("dimension");
|
||||
world.dimension?.dispose();
|
||||
world.dimension = new Dimension(world, world.connection.seed);
|
||||
for (const [x, y, z, id, state] of world.connection.initial_changes) {
|
||||
world.dimension.record_change(x, y, z, id, state);
|
||||
}
|
||||
dimension.add(world.dimension);
|
||||
world.add_entity(dimension);
|
||||
|
||||
create_player(world);
|
||||
|
||||
// UI !
|
||||
const unpause_button = new Entity("unpausebutton");
|
||||
unpause_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
|
||||
unpause_button.add(new UIButton("Unpause", 320, 64, () => world.state = "paused"));
|
||||
world.add_entity(unpause_button);
|
||||
|
||||
const about_button = new Entity("aboutbutton");
|
||||
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
|
||||
about_button.add(new UIButton("About", 320, 64, () => open_about()));
|
||||
world.add_entity(about_button);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { AIR, FACE_OFFSETS, TICK_DELTA } from "$/common/constants.ts";
|
||||
import type { Client } from "./client.ts";
|
||||
import type { BlockHitResult } from "./level/client_level.ts";
|
||||
import type { Container } from "$/common/inventory.ts";
|
||||
import type { ContainerKey } from "$/common/protocol.ts";
|
||||
|
||||
// breaking and using blocks against a server, like minecraft's MultiPlayerGameMode. the client times breaking
|
||||
// and guesses the results so they feel instant, the server decides what really happens
|
||||
export class MultiPlayerGameMode {
|
||||
client: Client;
|
||||
|
||||
// the block being broken, and how far along it is in seconds out of destroy_time
|
||||
destroy_pos: { x: number; y: number; z: number } | undefined;
|
||||
destroy_progress = 0;
|
||||
destroy_time = 0;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
// the click that starts breaking, for mods' on_click
|
||||
start_destroy_block(hit: BlockHitResult) {
|
||||
this.client.connection.send({ type: "hit_block", x: hit.x, y: hit.y, z: hit.z });
|
||||
}
|
||||
|
||||
// called every tick the attack button is held on a block
|
||||
continue_destroy_block(hit: BlockHitResult) {
|
||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", hit.block)!;
|
||||
const tool_type = this.#held_item()?.tool_type;
|
||||
|
||||
this.destroy_pos = { x: hit.x, y: hit.y, z: hit.z };
|
||||
this.destroy_time = block_info.toughness ?? 9999;
|
||||
this.destroy_progress += TICK_DELTA * (tool_type === block_info.tool_to_break ? 2 : 1);
|
||||
|
||||
if (this.destroy_progress >= this.destroy_time) {
|
||||
// show it right away, the server decides drops and corrects us if it disagrees
|
||||
this.client.level.break_block(hit.x, hit.y, hit.z);
|
||||
this.client.connection.send({ type: "break_block", x: hit.x, y: hit.y, z: hit.z });
|
||||
this.destroy_progress = 0;
|
||||
this.destroy_time = 0;
|
||||
}
|
||||
}
|
||||
|
||||
stop_destroy_block() {
|
||||
this.destroy_pos = undefined;
|
||||
this.destroy_progress = 0;
|
||||
this.destroy_time = 0;
|
||||
}
|
||||
|
||||
// right click on a block
|
||||
use_item_on(hit: BlockHitResult) {
|
||||
const level = this.client.level;
|
||||
this.client.connection.send({ type: "use_block", x: hit.x, y: hit.y, z: hit.z, face: hit.face });
|
||||
|
||||
// guess that it places the held block, unless the block does something when used
|
||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", hit.block)!;
|
||||
const offset = FACE_OFFSETS[hit.face];
|
||||
const target = { x: hit.x + offset.x, y: hit.y + offset.y, z: hit.z + offset.z };
|
||||
const target_id = level.get_block(target.x, target.y, target.z);
|
||||
const replaceable = target_id === AIR ||
|
||||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
|
||||
const held = this.#held_item();
|
||||
// items with components might do something else on the server, like on_use
|
||||
const place_id = held?.components ? undefined : held?.block_id;
|
||||
if (!block_info.interactive && place_id && replaceable) {
|
||||
level.add_block({ ...target, id: place_id });
|
||||
const slot = this.#held_slot();
|
||||
slot.amount = slot.amount! - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// q on a slot: throws one, or the whole stack. the server spawns the item, this only takes it out right away
|
||||
drop_item(container: Container, key: ContainerKey, index: number, all: boolean) {
|
||||
const item = container.get_item(index);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
this.client.connection.send({ type: "drop_item", container: key, index, all });
|
||||
item.amount = all ? 0 : item.amount - 1;
|
||||
container.set_item(index, item.amount > 0 ? item : undefined);
|
||||
}
|
||||
|
||||
// right click on nothing
|
||||
use_item() {
|
||||
this.client.connection.send({ type: "use_item" });
|
||||
}
|
||||
|
||||
#held_slot() {
|
||||
const inventories = this.client.player.inventories;
|
||||
return inventories.inventory.get_slot(inventories.hotbar_selected);
|
||||
}
|
||||
|
||||
#held_item() {
|
||||
return EverythingRegistry.get<ItemRegistry>("items", this.#held_slot().type_id ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts";
|
||||
|
||||
const LINE_HEIGHT = 24;
|
||||
const VISIBLE_SECONDS = 10;
|
||||
const MAX_LINES = 10;
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
interface ChatLine {
|
||||
text: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
// the chat log, like minecraft's ChatComponent
|
||||
export class ChatComponent {
|
||||
lines: ChatLine[] = [];
|
||||
|
||||
add(text: string) {
|
||||
this.lines.push({ text, time: performance.now() });
|
||||
if (this.lines.length > MAX_HISTORY) {
|
||||
this.lines.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// above the bottom left corner. `all` shows old messages too, for when the chat is open
|
||||
render(all: boolean, bottom = canvas.height - 100) {
|
||||
const now = performance.now();
|
||||
const lines = this.lines
|
||||
.filter((line) => all || now - line.time < VISIBLE_SECONDS * 1000)
|
||||
.slice(-MAX_LINES);
|
||||
|
||||
let y = bottom - lines.length * LINE_HEIGHT;
|
||||
for (const line of lines) {
|
||||
draw_rect(0, y, 600, LINE_HEIGHT, [0, 0, 0, 0.4]);
|
||||
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
|
||||
y += LINE_HEIGHT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Marked } from "marked";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { mod_credits } from "$/client/mods.ts";
|
||||
import { canvas, draw_rect } from "$/client/renderer/mod.ts";
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
|
||||
// credits come from servers' mods, so markdown only: raw html is shown as text and links can't run code
|
||||
const markdown = new Marked({
|
||||
renderer: {
|
||||
html({ text }) {
|
||||
return escape_html(text);
|
||||
},
|
||||
link({ href, title, tokens }) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:)/i.test(href)) {
|
||||
return text;
|
||||
}
|
||||
const title_attribute = title ? ` title="${escape_html(title)}"` : "";
|
||||
return `<a href="${
|
||||
escape_html(href)
|
||||
}"${title_attribute} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// who made what: the engine's assets/ASSETS.md, then each loaded mod's credits file. shown as a page over the
|
||||
// game since licenses are long, the game underneath keeps drawing
|
||||
export class CreditsScreen extends GuiScreen {
|
||||
#overlay: HTMLElement;
|
||||
|
||||
constructor(on_back: () => void) {
|
||||
super();
|
||||
|
||||
this.#overlay = document.createElement("div");
|
||||
this.#overlay.style.cssText = "position:fixed;inset:0;display:flex;justify-content:center;padding:32px 16px;" +
|
||||
"box-sizing:border-box;color:#e5e7eb;font:15px/1.6 system-ui,sans-serif;";
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.style.cssText = "width:100%;max-width:760px;display:flex;flex-direction:column;gap:12px;" +
|
||||
"background:rgba(17,24,39,0.95);border-radius:8px;padding:20px 24px;box-sizing:border-box;";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:12px;";
|
||||
const title = document.createElement("div");
|
||||
title.textContent = "Credits";
|
||||
title.style.cssText = "font-size:24px;font-weight:600;";
|
||||
const back = document.createElement("button");
|
||||
back.textContent = "Back";
|
||||
back.style.cssText = "font:inherit;padding:6px 20px;cursor:pointer;";
|
||||
back.addEventListener("click", on_back);
|
||||
header.append(title, back);
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.style.cssText = "overflow-y:auto;flex:1;min-height:0;padding-right:8px;";
|
||||
content.innerHTML = this.#sections().map(({ heading, text }) =>
|
||||
`<section><h2 style="border-bottom:1px solid #374151;padding-bottom:4px">${escape_html(heading)}</h2>` +
|
||||
`${markdown.parse(text, { async: false })}</section>`
|
||||
).join("");
|
||||
for (const pre of content.querySelectorAll("pre")) {
|
||||
pre.style.cssText =
|
||||
"white-space:pre-wrap;background:#0b1220;padding:12px;border-radius:6px;font-size:12px;";
|
||||
}
|
||||
for (const link of content.querySelectorAll("a")) {
|
||||
link.style.color = "#60a5fa";
|
||||
}
|
||||
|
||||
panel.append(header, content);
|
||||
this.#overlay.append(panel);
|
||||
document.body.append(this.#overlay);
|
||||
}
|
||||
|
||||
#sections() {
|
||||
const sections = [{ heading: "bworld", text: AssetManager.instance.get<string>("bworld:assets_text") ?? "" }];
|
||||
for (const mod of mod_credits) {
|
||||
sections.push({ heading: `${mod.name} ${mod.version}`, text: mod.text });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
on_tick(_delta: number): void {}
|
||||
|
||||
on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
||||
}
|
||||
|
||||
on_close(): void {
|
||||
this.#overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function escape_html(text: string) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import { DebugUI } from "$/client/debug_ui.ts";
|
||||
|
||||
// f3: every entity's fields, editable
|
||||
export class DebugOverlay {
|
||||
render(client: Client) {
|
||||
DebugUI.begin("Entities", 10, 10, 300);
|
||||
|
||||
for (const entity of client.level.entities.values()) {
|
||||
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
|
||||
this.#render_fields(entity);
|
||||
}
|
||||
}
|
||||
if (DebugUI.collapsing_header("Camera")) {
|
||||
this.#render_fields(client.camera);
|
||||
}
|
||||
if (DebugUI.collapsing_header("Options")) {
|
||||
this.#render_fields(client.options);
|
||||
}
|
||||
|
||||
DebugUI.end();
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
#render_fields(object: any) {
|
||||
for (const key in object) {
|
||||
const value = object[key];
|
||||
if (typeof value === "number") {
|
||||
object[key] = DebugUI.float_input(key, value);
|
||||
} else if (typeof value === "string") {
|
||||
object[key] = DebugUI.text_input(key, value);
|
||||
} else if (typeof value === "boolean") {
|
||||
object[key] = DebugUI.checkbox(key, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
DebugUI.text(`${key}: ${JSON.stringify(value.slice(0, 10))}`);
|
||||
} else if (value && typeof value === "object" && value.constructor !== Object) {
|
||||
// other objects like the level point back at this one, only name them
|
||||
DebugUI.text(`${key}: ${value.constructor.name}`);
|
||||
} else {
|
||||
DebugUI.text(`${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
DebugUI.separator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { back_to_title } from "./title_screen.ts";
|
||||
import { Button, TEXT_HEIGHT, TEXT_SCALE } from "./widgets.ts";
|
||||
|
||||
const BUTTON_WIDTH = 440;
|
||||
const BUTTON_HEIGHT = 48;
|
||||
|
||||
// why the game stopped, like minecraft's DisconnectedScreen
|
||||
export class DisconnectedScreen extends GuiScreen {
|
||||
message: string;
|
||||
back = new Button("Back to title screen", BUTTON_WIDTH, BUTTON_HEIGHT, back_to_title);
|
||||
|
||||
constructor(message: string) {
|
||||
super();
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
on_tick(_delta: number): void {
|
||||
this.back.x = (canvas.width - BUTTON_WIDTH) / 2;
|
||||
this.back.y = canvas.height / 2 + 20;
|
||||
this.back.handle_input();
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.6]);
|
||||
this.#centered("Disconnected", canvas.height / 2 - 90, 4, [1, 1, 1, 1]);
|
||||
this.#centered(this.message, canvas.height / 2 - 30, TEXT_SCALE, [0.85, 0.85, 0.85, 1]);
|
||||
this.back.render();
|
||||
}
|
||||
|
||||
on_close(): void {}
|
||||
|
||||
#centered(text: string, y: number, scale: number, color: number[]) {
|
||||
draw_text(text, (canvas.width - measure_text(text, scale)) / 2, y - (TEXT_HEIGHT * scale) / 2, scale, color);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +1,44 @@
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts";
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import type { Client } from "../client.ts";
|
||||
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
|
||||
import { render_chat_log } from "../systems/rendering/network.ts";
|
||||
import { TextInput } from "./widgets.ts";
|
||||
|
||||
export class GuiChat extends GuiScreen {
|
||||
world: ClientWorld;
|
||||
client: Client;
|
||||
input = new TextInput("", MAX_CHAT_LENGTH);
|
||||
|
||||
text_typed = "";
|
||||
caret = 0;
|
||||
key_repeat_timer = 0;
|
||||
show_caret = true;
|
||||
|
||||
constructor(world: ClientWorld) {
|
||||
constructor(client: Client) {
|
||||
super();
|
||||
this.world = world;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
override on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
|
||||
|
||||
const y = canvas.height - 32;
|
||||
render_chat_log(this.world, true, y - 8);
|
||||
this.client.chat.render(true, y - 8);
|
||||
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
|
||||
|
||||
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]);
|
||||
|
||||
if (this.show_caret) {
|
||||
const before_text = this.text_typed.substring(0, this.caret);
|
||||
const caret_x = 0 + measure_text(before_text, 2);
|
||||
|
||||
draw_rect(caret_x, y + 4, 1, 32 - 4);
|
||||
}
|
||||
draw_text(this.input.value, 0, y, 2, [1, 1, 1]);
|
||||
draw_rect(this.input.caret_x(0, 2), y + 4, 1, 32 - 4);
|
||||
}
|
||||
|
||||
override on_tick(_delta: number): void {
|
||||
const now = performance.now();
|
||||
const repeat_delay = 400;
|
||||
const repeat_rate = 40;
|
||||
|
||||
const allow_repeat = () => {
|
||||
if (InputManager.is_key_pressed("Backspace")) {
|
||||
this.key_repeat_timer = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (InputManager.is_key_down("Backspace")) {
|
||||
if (now - this.key_repeat_timer > repeat_delay) {
|
||||
this.key_repeat_timer = now - (repeat_delay - repeat_rate);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (allow_repeat()) {
|
||||
if (this.caret > 0) {
|
||||
this.text_typed = this.text_typed.slice(0, this.caret - 1) + this.text_typed.slice(this.caret);
|
||||
this.caret -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("Delete")) {
|
||||
this.text_typed = this.text_typed.slice(0, this.caret) + this.text_typed.slice(this.caret + 1);
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("ArrowLeft")) {
|
||||
this.caret = Math.max(0, this.caret - 1);
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("ArrowRight")) {
|
||||
this.caret = Math.min(this.text_typed.length, this.caret + 1);
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("Home")) {
|
||||
this.caret = 0;
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("End")) {
|
||||
this.caret = this.text_typed.length;
|
||||
}
|
||||
|
||||
const typed = InputManager.get_typed_characters();
|
||||
|
||||
for (const char of typed) {
|
||||
if (this.text_typed.length >= MAX_CHAT_LENGTH) {
|
||||
break;
|
||||
}
|
||||
this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret);
|
||||
this.caret += 1;
|
||||
}
|
||||
|
||||
this.input.handle_keys();
|
||||
if (InputManager.is_key_pressed("Enter")) {
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
|
||||
override on_close(): void {}
|
||||
|
||||
submit() {
|
||||
// commands like /give run on the server too
|
||||
if (this.text_typed.trim().length > 0) {
|
||||
this.world.connection.send({ type: "chat", text: this.text_typed });
|
||||
}
|
||||
this.text_typed = "";
|
||||
|
||||
const [player] = this.world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent);
|
||||
if (player_component) {
|
||||
player_component.pop_screen();
|
||||
if (this.input.value.trim().length > 0) {
|
||||
this.client.connection.send({ type: "chat", text: this.input.value });
|
||||
}
|
||||
this.client.pop_screen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/render
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
|
||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
import { draw_nine_slice } from "../rendering/render_utils.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } fro
|
||||
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { SLOT_SIZE } from "../../common/constants.ts";
|
||||
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
import { draw_nine_slice } from "../rendering/render_utils.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AssetManager } from "../assets.ts";
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { ClientInventories } from "../inventory.ts";
|
||||
import { canvas, Texture } from "../renderer/mod.ts";
|
||||
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
|
||||
import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts";
|
||||
|
||||
export class Slot {
|
||||
container: ContainerKey;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
|
||||
export class GuiRenderSystem extends System {
|
||||
update(world: ClientWorld, _delta: number): void {
|
||||
const [player] = world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
|
||||
player_component.screens.at(-1)?.on_render();
|
||||
}
|
||||
}
|
||||
|
||||
export class GuiTickSystem extends System {
|
||||
update(world: ClientWorld, delta: number): void {
|
||||
const [player] = world.get_tag("player")!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
|
||||
player_component.screens.at(-1)?.on_tick(delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SLOT_SIZE } from "$/common/constants.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import type { ClientInventories } from "$/client/inventory.ts";
|
||||
import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.ts";
|
||||
import { canvas, draw_rect_stroke, Texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
const CROSSHAIR_SIZE = 8;
|
||||
|
||||
// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat
|
||||
export class Hud {
|
||||
render(client: Client) {
|
||||
this.#render_hotbar(client.player.inventories);
|
||||
this.#render_crosshair();
|
||||
client.chat.render(false);
|
||||
}
|
||||
|
||||
#render_hotbar(inventories: ClientInventories) {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
const hotbar_height = PADDING * 2 + SLOT_SIZE;
|
||||
|
||||
const x = canvas.width / 2 - hotbar_width / 2;
|
||||
const y = canvas.height - hotbar_height;
|
||||
|
||||
draw_nine_slice(ui, 160, 0, 16, 16, 4, 4, 4, 4, x, y, hotbar_width, hotbar_height);
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selected = inventories.hotbar_selected === index;
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
selected ? 19 * 16 : 160 + 32,
|
||||
selected ? 16 : 0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
x + PADDING + index * SLOT_SIZE,
|
||||
y + PADDING,
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const item = inventories.inventory.get_item(index);
|
||||
if (item) {
|
||||
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#render_crosshair() {
|
||||
draw_rect_stroke(
|
||||
(canvas.width - CROSSHAIR_SIZE) / 2,
|
||||
(canvas.height - CROSSHAIR_SIZE) / 2,
|
||||
CROSSHAIR_SIZE,
|
||||
CROSSHAIR_SIZE,
|
||||
[0, 0, 0, 0.6],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { Button, TEXT_HEIGHT } from "./widgets.ts";
|
||||
import { CreditsScreen } from "./credits_screen.ts";
|
||||
|
||||
const BUTTON_WIDTH = 440;
|
||||
const BUTTON_HEIGHT = 48;
|
||||
const GAP = 16;
|
||||
|
||||
// escape while playing, like minecraft's PauseScreen. the server keeps going, it's only a menu
|
||||
export class PauseScreen extends GuiScreen {
|
||||
buttons: Button[];
|
||||
|
||||
constructor(client: Client) {
|
||||
super();
|
||||
this.buttons = [
|
||||
new Button("Back to game", BUTTON_WIDTH, BUTTON_HEIGHT, () => client.pop_screen()),
|
||||
new Button("Credits", BUTTON_WIDTH, BUTTON_HEIGHT, () => {
|
||||
client.push_screen(new CreditsScreen(() => client.pop_screen()));
|
||||
}),
|
||||
new Button("Disconnect", BUTTON_WIDTH, BUTTON_HEIGHT, () => client.disconnect()),
|
||||
];
|
||||
}
|
||||
|
||||
on_tick(_delta: number): void {
|
||||
let y = canvas.height / 2 - BUTTON_HEIGHT;
|
||||
for (const button of this.buttons) {
|
||||
button.x = (canvas.width - BUTTON_WIDTH) / 2;
|
||||
button.y = y;
|
||||
y += BUTTON_HEIGHT + GAP;
|
||||
}
|
||||
for (const button of this.buttons) {
|
||||
if (button.handle_input()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.5]);
|
||||
const title = "Game menu";
|
||||
draw_text(title, (canvas.width - measure_text(title, 4)) / 2, this.buttons[0].y - 40 - TEXT_HEIGHT * 4, 4);
|
||||
for (const button of this.buttons) {
|
||||
button.render();
|
||||
}
|
||||
}
|
||||
|
||||
on_close(): void {}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { MAX_NAME_LENGTH } from "$/common/protocol.ts";
|
||||
import { InputManager } from "$/client/input_manager.ts";
|
||||
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
|
||||
import { default_server, HandshakeError, server_address, ServerAddress } from "$/client/handshake.ts";
|
||||
import { default_player_name } from "$/client/network.ts";
|
||||
import { GuiScreen } from "./gui_screen.ts";
|
||||
import { CreditsScreen } from "./credits_screen.ts";
|
||||
import { Button, EditBox, TEXT_HEIGHT, TEXT_SCALE, TextInput } from "./widgets.ts";
|
||||
|
||||
const LAST_SERVER_KEY = "bworld:last_server";
|
||||
const LAST_NAME_KEY = "bworld:last_name";
|
||||
|
||||
const WIDTH = 440;
|
||||
const ROW_HEIGHT = 48;
|
||||
const LABEL_GAP = 28;
|
||||
const GAP = 20;
|
||||
const TITLE_SCALE = 8;
|
||||
|
||||
// connects to a server and joins it. it reports progress through status, and throws with a message
|
||||
// for the player when it fails
|
||||
export type JoinServer = (address: ServerAddress, name: string, status: (text: string) => void) => Promise<void>;
|
||||
|
||||
// the first thing players see, like minecraft's title and multiplayer screens rolled into one:
|
||||
// a name, a server, and a button to join it
|
||||
export class TitleScreen extends GuiScreen {
|
||||
#join_server: JoinServer;
|
||||
|
||||
name = new EditBox(
|
||||
WIDTH,
|
||||
ROW_HEIGHT,
|
||||
new TextInput(initial_name(), MAX_NAME_LENGTH, (char) => /^[A-Za-z0-9_]$/.test(char)),
|
||||
"Random name",
|
||||
);
|
||||
server = new EditBox(WIDTH, ROW_HEIGHT, new TextInput(initial_server(), 256, (char) => char !== " "), "host:port");
|
||||
join_button = new Button("Join server", WIDTH, ROW_HEIGHT, () => this.#join());
|
||||
credits_button = new Button("Credits", WIDTH, ROW_HEIGHT, () => this.#open_credits());
|
||||
// open over the title screen, it gets the input while it's there
|
||||
#credits: CreditsScreen | undefined;
|
||||
|
||||
status = "";
|
||||
status_is_error = false;
|
||||
#joining = false;
|
||||
|
||||
constructor(join_server: JoinServer, message?: string) {
|
||||
super();
|
||||
this.#join_server = join_server;
|
||||
this.name.focused = this.name.value === "";
|
||||
this.server.focused = !this.name.focused;
|
||||
if (message) {
|
||||
this.#show_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
on_tick(delta: number): void {
|
||||
this.#layout();
|
||||
if (this.#credits) {
|
||||
if (InputManager.is_key_pressed("Escape")) {
|
||||
this.#close_credits();
|
||||
} else {
|
||||
this.#credits.on_tick(delta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = [this.name, this.server];
|
||||
for (const field of fields) {
|
||||
if (field.handle_input()) {
|
||||
for (const other of fields) other.focused = other === field;
|
||||
}
|
||||
}
|
||||
if (InputManager.is_key_pressed("Tab")) {
|
||||
const next = this.name.focused ? this.server : this.name;
|
||||
for (const field of fields) field.focused = field === next;
|
||||
}
|
||||
if (InputManager.is_key_pressed("Enter")) {
|
||||
this.#join();
|
||||
}
|
||||
this.join_button.handle_input();
|
||||
this.credits_button.handle_input();
|
||||
}
|
||||
|
||||
on_render(): void {
|
||||
const title = "bworld";
|
||||
const title_width = measure_text(title, TITLE_SCALE);
|
||||
draw_text(
|
||||
title,
|
||||
(canvas.width - title_width) / 2,
|
||||
this.name.y - LABEL_GAP - 40 - TEXT_HEIGHT * TITLE_SCALE,
|
||||
TITLE_SCALE,
|
||||
);
|
||||
|
||||
this.#label("Name", this.name);
|
||||
this.#label("Server", this.server);
|
||||
this.name.render();
|
||||
this.server.render();
|
||||
this.join_button.render();
|
||||
this.credits_button.render();
|
||||
|
||||
if (this.status) {
|
||||
const width = measure_text(this.status, TEXT_SCALE);
|
||||
const x = (canvas.width - width) / 2;
|
||||
const y = this.credits_button.y + ROW_HEIGHT + GAP;
|
||||
draw_rect(x - 8, y - 4, width + 16, TEXT_HEIGHT * TEXT_SCALE + 8, [0, 0, 0, 0.5]);
|
||||
draw_text(this.status, x, y, TEXT_SCALE, this.status_is_error ? [1, 0.45, 0.45, 1] : [1, 1, 1, 1]);
|
||||
}
|
||||
this.#credits?.on_render();
|
||||
}
|
||||
|
||||
on_close(): void {
|
||||
this.#close_credits();
|
||||
}
|
||||
|
||||
#open_credits() {
|
||||
this.#credits ??= new CreditsScreen(() => this.#close_credits());
|
||||
}
|
||||
|
||||
#close_credits() {
|
||||
this.#credits?.on_close();
|
||||
this.#credits = undefined;
|
||||
}
|
||||
|
||||
#layout() {
|
||||
const x = (canvas.width - WIDTH) / 2;
|
||||
const total = 2 * (LABEL_GAP + ROW_HEIGHT + GAP) + 2 * ROW_HEIGHT + GAP;
|
||||
let y = (canvas.height - total) / 2 + 40;
|
||||
for (const field of [this.name, this.server]) {
|
||||
field.x = x;
|
||||
field.y = y + LABEL_GAP;
|
||||
y += LABEL_GAP + ROW_HEIGHT + GAP;
|
||||
}
|
||||
this.join_button.x = x;
|
||||
this.join_button.y = y;
|
||||
this.credits_button.x = x;
|
||||
this.credits_button.y = y + ROW_HEIGHT + GAP;
|
||||
}
|
||||
|
||||
#label(text: string, field: EditBox) {
|
||||
draw_text(text, field.x, field.y - LABEL_GAP, TEXT_SCALE, [0.15, 0.15, 0.2, 1]);
|
||||
}
|
||||
|
||||
async #join() {
|
||||
if (this.#joining) {
|
||||
return;
|
||||
}
|
||||
|
||||
let address: ServerAddress;
|
||||
try {
|
||||
address = server_address(this.server.value);
|
||||
} catch (e) {
|
||||
this.#show_error((e as HandshakeError).message);
|
||||
return;
|
||||
}
|
||||
remember(LAST_SERVER_KEY, this.server.value.trim());
|
||||
remember(LAST_NAME_KEY, this.name.value);
|
||||
|
||||
this.#set_joining(true);
|
||||
this.status = `Connecting to ${address.base.host}...`;
|
||||
this.status_is_error = false;
|
||||
try {
|
||||
await this.#join_server(address, this.name.value, (text) => this.status = text);
|
||||
} catch (e) {
|
||||
this.#show_error(e instanceof Error ? e.message : String(e));
|
||||
this.#set_joining(false);
|
||||
}
|
||||
}
|
||||
|
||||
#set_joining(joining: boolean) {
|
||||
this.#joining = joining;
|
||||
this.name.active = this.server.active = this.join_button.active = !joining;
|
||||
}
|
||||
|
||||
#show_error(message: string) {
|
||||
this.status = message;
|
||||
this.status_is_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
// what was typed last time, unless the page was opened with ?server= or ?name=
|
||||
function initial_server() {
|
||||
const page = new URL(location.href);
|
||||
return page.searchParams.has("server") ? default_server(page) : recall(LAST_SERVER_KEY) ?? default_server(page);
|
||||
}
|
||||
|
||||
function initial_name() {
|
||||
const page = new URL(location.href);
|
||||
return page.searchParams.has("name") ? default_player_name() : recall(LAST_NAME_KEY) ?? "";
|
||||
}
|
||||
|
||||
function recall(key: string): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function remember(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// private windows and blocked storage just start empty next time
|
||||
}
|
||||
}
|
||||
|
||||
// a server's mods can't be unloaded, so going back to the title screen starts the page over. without ?server=
|
||||
// or ?name=, so the fields show what was used last
|
||||
export function back_to_title() {
|
||||
location.href = location.pathname;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { point_inside_rec } from "$/common/utils.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { InputManager } from "$/client/input_manager.ts";
|
||||
import { draw_nine_slice } from "$/client/rendering/render_utils.ts";
|
||||
import { draw_rect, draw_text, measure_text, Texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
export const TEXT_SCALE = 2;
|
||||
// how tall the font is at scale 1
|
||||
export const TEXT_HEIGHT = 11;
|
||||
|
||||
const REPEAT_DELAY_MS = 400;
|
||||
const REPEAT_RATE_MS = 40;
|
||||
const CARET_BLINK_MS = 500;
|
||||
|
||||
// editing a line of text with the keyboard: typing, backspace (held repeats), delete, arrows, home and end
|
||||
export class TextInput {
|
||||
value: string;
|
||||
caret: number;
|
||||
max_length: number;
|
||||
// which typed characters are kept
|
||||
allowed: (char: string) => boolean;
|
||||
|
||||
#repeat_timer = 0;
|
||||
|
||||
constructor(value = "", max_length = 256, allowed: (char: string) => boolean = () => true) {
|
||||
this.value = value;
|
||||
this.caret = value.length;
|
||||
this.max_length = max_length;
|
||||
this.allowed = allowed;
|
||||
}
|
||||
|
||||
// call once per frame while it has the keyboard
|
||||
handle_keys() {
|
||||
const now = performance.now();
|
||||
let backspace = false;
|
||||
if (InputManager.is_key_pressed("Backspace")) {
|
||||
this.#repeat_timer = now;
|
||||
backspace = true;
|
||||
} else if (InputManager.is_key_down("Backspace") && now - this.#repeat_timer > REPEAT_DELAY_MS) {
|
||||
this.#repeat_timer = now - (REPEAT_DELAY_MS - REPEAT_RATE_MS);
|
||||
backspace = true;
|
||||
}
|
||||
if (backspace && this.caret > 0) {
|
||||
this.value = this.value.slice(0, this.caret - 1) + this.value.slice(this.caret);
|
||||
this.caret -= 1;
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("Delete")) {
|
||||
this.value = this.value.slice(0, this.caret) + this.value.slice(this.caret + 1);
|
||||
}
|
||||
if (InputManager.is_key_pressed("ArrowLeft")) {
|
||||
this.caret = Math.max(0, this.caret - 1);
|
||||
}
|
||||
if (InputManager.is_key_pressed("ArrowRight")) {
|
||||
this.caret = Math.min(this.value.length, this.caret + 1);
|
||||
}
|
||||
if (InputManager.is_key_pressed("Home")) {
|
||||
this.caret = 0;
|
||||
}
|
||||
if (InputManager.is_key_pressed("End")) {
|
||||
this.caret = this.value.length;
|
||||
}
|
||||
|
||||
for (const char of InputManager.get_typed_characters()) {
|
||||
if (this.value.length >= this.max_length || !this.allowed(char)) {
|
||||
continue;
|
||||
}
|
||||
this.value = this.value.slice(0, this.caret) + char + this.value.slice(this.caret);
|
||||
this.caret += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// where the caret goes when the text starts at x
|
||||
caret_x(x: number, scale = TEXT_SCALE) {
|
||||
return x + measure_text(this.value.slice(0, this.caret), scale);
|
||||
}
|
||||
|
||||
static caret_visible() {
|
||||
return Math.floor(performance.now() / CARET_BLINK_MS) % 2 === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// a clickable button, like minecraft's Button
|
||||
export class Button {
|
||||
x = 0;
|
||||
y = 0;
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
on_press: () => void;
|
||||
active = true;
|
||||
#hovered = false;
|
||||
|
||||
constructor(label: string, width: number, height: number, on_press: () => void) {
|
||||
this.label = label;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.on_press = on_press;
|
||||
}
|
||||
|
||||
// returns whether it was pressed
|
||||
handle_input(): boolean {
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
this.#hovered = this.active && point_inside_rec(mouse.x, mouse.y, this.x, this.y, this.width, this.height);
|
||||
if (this.#hovered && InputManager.is_mouse_pressed(0)) {
|
||||
InputManager.consume_mouse(0);
|
||||
this.on_press();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
const [sx, sy] = this.#hovered ? [224, 16] : [176, 0];
|
||||
const tint = this.active ? [1, 1, 1, 1] : [0.6, 0.6, 0.6, 1];
|
||||
draw_nine_slice(ui, sx, sy, 16, 16, 4, 4, 4, 4, this.x, this.y, this.width, this.height, tint);
|
||||
|
||||
const text_width = measure_text(this.label, TEXT_SCALE);
|
||||
draw_text(
|
||||
this.label,
|
||||
this.x + (this.width - text_width) / 2,
|
||||
this.y + (this.height - TEXT_HEIGHT * TEXT_SCALE) / 2,
|
||||
TEXT_SCALE,
|
||||
this.active ? [1, 1, 1, 1] : [0.7, 0.7, 0.7, 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// a one line text field, like minecraft's EditBox. clicking it gives it the keyboard
|
||||
export class EditBox {
|
||||
x = 0;
|
||||
y = 0;
|
||||
width: number;
|
||||
height: number;
|
||||
input: TextInput;
|
||||
focused = false;
|
||||
// shown greyed out while it's empty
|
||||
hint: string;
|
||||
active = true;
|
||||
|
||||
constructor(width: number, height: number, input: TextInput, hint = "") {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.input = input;
|
||||
this.hint = hint;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.input.value;
|
||||
}
|
||||
|
||||
// returns whether it was clicked, so the screen can move focus to it
|
||||
handle_input(): boolean {
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
const clicked = this.active && InputManager.is_mouse_pressed(0) &&
|
||||
point_inside_rec(mouse.x, mouse.y, this.x, this.y, this.width, this.height);
|
||||
if (clicked) {
|
||||
InputManager.consume_mouse(0);
|
||||
}
|
||||
if (this.focused && this.active) {
|
||||
this.input.handle_keys();
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
render() {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
draw_nine_slice(ui, this.focused ? 320 : 304, 0, 16, 16, 4, 4, 4, 4, this.x, this.y, this.width, this.height);
|
||||
|
||||
const text_x = this.x + 10;
|
||||
const text_y = this.y + (this.height - TEXT_HEIGHT * TEXT_SCALE) / 2;
|
||||
if (this.value === "" && !this.focused) {
|
||||
draw_text(this.hint, text_x, text_y, TEXT_SCALE, [0.6, 0.6, 0.6, 1]);
|
||||
} else {
|
||||
draw_text(this.value, text_x, text_y, TEXT_SCALE, this.active ? [1, 1, 1, 1] : [0.7, 0.7, 0.7, 1]);
|
||||
}
|
||||
if (this.focused && this.active && TextInput.caret_visible()) {
|
||||
draw_rect(this.input.caret_x(text_x), text_y, 2, TEXT_HEIGHT * TEXT_SCALE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,30 @@ export interface ServerAddress {
|
||||
// something went wrong in a way the player should see
|
||||
export class HandshakeError extends Error {}
|
||||
|
||||
export function server_address(page = new URL(location.href)): ServerAddress {
|
||||
// ?server=host:port, otherwise the server that served the page
|
||||
const host = page.searchParams.get("server") ?? page.host;
|
||||
const secure = page.protocol === "https:";
|
||||
// what the server field starts as: ?server=host:port, otherwise the server that served the page
|
||||
export function default_server(page = new URL(location.href)): string {
|
||||
return page.searchParams.get("server") ?? page.host;
|
||||
}
|
||||
|
||||
// what a player typed as the server: host:port, or a full http(s) or ws(s) url
|
||||
export function server_address(input: string, page = new URL(location.href)): ServerAddress {
|
||||
const text = input.trim();
|
||||
let host = text;
|
||||
// without a scheme it's as secure as the page, browsers block insecure sockets from secure pages anyway
|
||||
let secure = page.protocol === "https:";
|
||||
if (text.includes("://")) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
throw new HandshakeError(`${text} isn't a server address`);
|
||||
}
|
||||
host = url.host;
|
||||
secure = url.protocol === "https:" || url.protocol === "wss:";
|
||||
}
|
||||
if (!host || /[\s/?#]/.test(host)) {
|
||||
throw new HandshakeError(`${text || "An empty address"} isn't a server address`);
|
||||
}
|
||||
const base = new URL(`${secure ? "https" : "http"}://${host}/`);
|
||||
return {
|
||||
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
|
||||
|
||||
@@ -96,6 +96,8 @@ export class InputManager {
|
||||
static mouse_ungrab_timer = 0;
|
||||
static mouse_ungrab_timeout = -1;
|
||||
static pointer_lock_waiting = false;
|
||||
// the browser let go of the mouse while the game wanted it, like escape or switching windows
|
||||
static #lost_pointer_lock = false;
|
||||
|
||||
static initialize(canvas: HTMLCanvasElement) {
|
||||
self.addEventListener("keydown", (e) => {
|
||||
@@ -164,6 +166,7 @@ export class InputManager {
|
||||
if (!grab) {
|
||||
if (this.pointer_lock_flag) {
|
||||
this.mouse_ungrab_timer = performance.now();
|
||||
this.#lost_pointer_lock = true;
|
||||
}
|
||||
}
|
||||
this.pointer_lock_flag = grab;
|
||||
@@ -266,6 +269,13 @@ export class InputManager {
|
||||
}
|
||||
}
|
||||
|
||||
// whether the mouse was taken away since the last call, browsers eat the escape that does it
|
||||
static take_lost_pointer_lock() {
|
||||
const lost = this.#lost_pointer_lock;
|
||||
this.#lost_pointer_lock = false;
|
||||
return lost;
|
||||
}
|
||||
|
||||
static is_mouse_grabbed() {
|
||||
return document.hasFocus() && document.pointerLockElement === canvas;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Component } from "$/common/ecs/mod.ts";
|
||||
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
|
||||
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
|
||||
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
|
||||
import {
|
||||
block_light_emission,
|
||||
block_light_opacity,
|
||||
BlockRegistry,
|
||||
EverythingRegistry,
|
||||
RENDER_LAYERS,
|
||||
RenderLayer,
|
||||
} from "$/common/everything_registry.ts";
|
||||
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { ChunkWorkerPool } from "../chunk_workers.ts";
|
||||
import { worldgen_mods } from "../mods.ts";
|
||||
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
|
||||
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
|
||||
import { Camera } from "./camera.ts";
|
||||
import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
|
||||
import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts";
|
||||
import { crosses_planes } from "../workers/translucent_sort.ts";
|
||||
import { Camera } from "../camera.ts";
|
||||
import type { Entity } from "../entity/entity.ts";
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
@@ -28,18 +35,52 @@ export interface Chunk {
|
||||
dirty: boolean;
|
||||
// bumped on every mesh request so late results from older requests get ignored
|
||||
mesh_version: number;
|
||||
opaque_vertex_buffer?: GPUBuffer;
|
||||
opaque_vertex_count?: number;
|
||||
transparent_vertex_buffer?: GPUBuffer;
|
||||
transparent_vertex_count?: number;
|
||||
meshes: Partial<Record<RenderLayer, ChunkMesh>>;
|
||||
// only for translucent meshes that have to be sorted again as the camera moves
|
||||
translucent_sort?: TranslucentSort;
|
||||
// blocks its generation put in neighboring chunks (leaves), as x, y, z, numeric id. kept so a neighbor that
|
||||
// generates later, or unloads and comes back, still gets them
|
||||
spills?: Int32Array;
|
||||
}
|
||||
|
||||
export interface ChunkMesh {
|
||||
vertex_buffer: GPUBuffer;
|
||||
quad_count: number;
|
||||
// the translucent layer's quads sorted back to front, the others are drawn in order
|
||||
index_buffer?: GPUBuffer;
|
||||
}
|
||||
|
||||
interface TranslucentSort {
|
||||
// the mesh_version of the mesh these are for
|
||||
mesh_version: number;
|
||||
centers: Float32Array;
|
||||
planes: [Float32Array, Float32Array, Float32Array];
|
||||
// where the camera was for the last sort that was requested
|
||||
camera: number[];
|
||||
// sorts come back out of order, older ones than what's shown get skipped
|
||||
requested: number;
|
||||
applied: number;
|
||||
}
|
||||
|
||||
const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
|
||||
|
||||
export { chunk_key };
|
||||
|
||||
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
|
||||
// the block being looked at and which face of it
|
||||
export interface BlockHitResult {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
block: number;
|
||||
face: Faces;
|
||||
}
|
||||
|
||||
export class Dimension extends Component {
|
||||
world: ClientWorld;
|
||||
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
|
||||
// light spreads diagonally too, so meshing and lighting need all 8
|
||||
const ALL_NEIGHBOR_OFFSETS = [...NEIGHBOR_OFFSETS, [-1, -1], [1, -1], [-1, 1], [1, 1]] as const;
|
||||
|
||||
// what minecraft calls the ClientLevel: this client's copy of the world, its chunks and the entities in it
|
||||
export class ClientLevel {
|
||||
image: Texture = AssetManager.instance.get("bworld:textures");
|
||||
chunks = new Map<number, Chunk>();
|
||||
second_timer = 0;
|
||||
@@ -50,12 +91,14 @@ export class Dimension extends Component {
|
||||
changes = new Map<string, Map<string, BlockChange>>();
|
||||
|
||||
workers: ChunkWorkerPool;
|
||||
#blocks = EverythingRegistry.get_registry<BlockRegistry>("blocks");
|
||||
// chunks being generated by a worker, by chunk key
|
||||
pending_generation = new Map<number, { x: number; z: number }>();
|
||||
|
||||
constructor(world: ClientWorld, seed = "seed") {
|
||||
super();
|
||||
this.world = world;
|
||||
// every entity this client knows about, the local player included, by id
|
||||
entities = new Map<string, Entity>();
|
||||
|
||||
constructor(seed = "seed") {
|
||||
this.seed = seed;
|
||||
|
||||
this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message));
|
||||
@@ -75,6 +118,64 @@ export class Dimension extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
add_entity(entity: Entity) {
|
||||
this.entities.set(entity.id, entity);
|
||||
}
|
||||
|
||||
remove_entity(id: string) {
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
tick() {
|
||||
for (const entity of this.entities.values()) {
|
||||
entity.save_previous_position();
|
||||
entity.tick();
|
||||
}
|
||||
}
|
||||
|
||||
// generates the chunks around a position and forgets the ones too far away. one extra ring past the render
|
||||
// distance gets generated so the edge has neighbors to mesh against
|
||||
update_loaded_chunks(x: number, z: number, render_distance: number) {
|
||||
const center_x = Math.floor(x / CHUNK_SIZE);
|
||||
const center_z = Math.floor(z / CHUNK_SIZE);
|
||||
const load_distance = render_distance + 1;
|
||||
const out_of_range = (cx: number, cz: number) =>
|
||||
Math.max(Math.abs(cx - center_x), Math.abs(cz - center_z)) > load_distance;
|
||||
|
||||
// collect first, deleting from the map while iterating it skips entries
|
||||
const to_unload = [...this.chunks.values()].filter((chunk) => out_of_range(chunk.x, chunk.z));
|
||||
for (const chunk of to_unload) {
|
||||
this.unload_chunk(chunk.x, chunk.z);
|
||||
}
|
||||
for (const pending of [...this.pending_generation.values()]) {
|
||||
if (out_of_range(pending.x, pending.z)) {
|
||||
this.cancel_chunk_request(pending.x, pending.z);
|
||||
}
|
||||
}
|
||||
|
||||
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
|
||||
const max_in_flight = this.workers.size * 2;
|
||||
if (this.pending_generation.size >= max_in_flight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing: { x: number; z: number; distance: number }[] = [];
|
||||
for (let cx = center_x - load_distance; cx <= center_x + load_distance; cx += 1) {
|
||||
for (let cz = center_z - load_distance; cz <= center_z + load_distance; cz += 1) {
|
||||
if (!this.is_generated(cx, cz) && !this.is_generating(cx, cz)) {
|
||||
const dx = cx - center_x;
|
||||
const dz = cz - center_z;
|
||||
missing.push({ x: cx, z: cz, distance: dx * dx + dz * dz });
|
||||
}
|
||||
}
|
||||
}
|
||||
missing.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const chunk of missing.slice(0, max_in_flight - this.pending_generation.size)) {
|
||||
this.request_chunk(chunk.x, chunk.z);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.workers.terminate();
|
||||
for (const chunk of this.chunks.values()) {
|
||||
@@ -92,6 +193,7 @@ export class Dimension extends Component {
|
||||
dirty: true,
|
||||
generated: false,
|
||||
mesh_version: 0,
|
||||
meshes: {},
|
||||
};
|
||||
this.chunks.set(chunk_key(x, z), chunk);
|
||||
return chunk;
|
||||
@@ -118,9 +220,10 @@ export class Dimension extends Component {
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
|
||||
const old_nid = chunk.blocks[index] & ID_MASK;
|
||||
chunk.blocks[index] = state === undefined ? default_block_value(nid, info) : block_value(nid, state);
|
||||
chunk.dirty = true;
|
||||
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
|
||||
this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, nid);
|
||||
}
|
||||
|
||||
// the id with its state bits, VOID outside loaded chunks
|
||||
@@ -154,6 +257,12 @@ export class Dimension extends Component {
|
||||
return chunk.blocks[index] & ID_MASK;
|
||||
}
|
||||
|
||||
// whether entities bump into the block there. unloaded chunks count as solid, so nothing falls out of the world
|
||||
has_collision(x: number, y: number, z: number) {
|
||||
const block = this.get_block(x, y, z);
|
||||
return block === VOID || (block !== AIR && (this.#blocks[block]?.has_collision ?? true));
|
||||
}
|
||||
|
||||
// only changes what this client shows, drops and everything else happen on the server
|
||||
break_block(x: number, y: number, z: number) {
|
||||
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
@@ -168,13 +277,37 @@ export class Dimension extends Component {
|
||||
const ly = y;
|
||||
|
||||
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
|
||||
const old_nid = chunk.blocks[index] & ID_MASK;
|
||||
chunk.blocks[index] = AIR;
|
||||
chunk.dirty = true;
|
||||
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
|
||||
this.#mark_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz, old_nid, AIR);
|
||||
}
|
||||
|
||||
// a block on a chunk's edge changes which faces its neighbor shows
|
||||
#mark_border_neighbors_dirty(block_chunk_x: number, block_chunk_z: number, lx: number, lz: number) {
|
||||
// a block that lets through or gives off a different amount of light changes the light up to 15 blocks
|
||||
// away, so in every neighbor. otherwise only a block on a chunk's edge matters, for its neighbor's faces
|
||||
#mark_neighbors_dirty(
|
||||
block_chunk_x: number,
|
||||
block_chunk_z: number,
|
||||
lx: number,
|
||||
lz: number,
|
||||
old_nid: number,
|
||||
new_nid: number,
|
||||
) {
|
||||
const old_block = this.#blocks[old_nid];
|
||||
const new_block = this.#blocks[new_nid];
|
||||
if (
|
||||
block_light_opacity(old_block) !== block_light_opacity(new_block) ||
|
||||
block_light_emission(old_block) !== block_light_emission(new_block)
|
||||
) {
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
const n = this.get_chunk(block_chunk_x + dx, block_chunk_z + dz);
|
||||
if (n) {
|
||||
n.dirty = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lx === 0) {
|
||||
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
|
||||
if (n) {
|
||||
@@ -279,13 +412,13 @@ export class Dimension extends Component {
|
||||
this.chunks.delete(key);
|
||||
}
|
||||
|
||||
// a chunk is only meshed once all its neighbors exist, otherwise its border faces would be wrong
|
||||
// and it would have to be meshed again as each neighbor loads
|
||||
// a chunk is only meshed once all its neighbors exist, otherwise its border faces and light would be
|
||||
// wrong and it would have to be meshed again as each neighbor loads
|
||||
can_mesh(chunk: Chunk) {
|
||||
if (!chunk.generated) {
|
||||
return false;
|
||||
}
|
||||
for (const [dx, dz] of NEIGHBOR_OFFSETS) {
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
if (!this.is_generated(chunk.x + dx, chunk.z + dz)) {
|
||||
return false;
|
||||
}
|
||||
@@ -294,29 +427,61 @@ export class Dimension extends Component {
|
||||
}
|
||||
|
||||
// sends every dirty chunk that can be meshed to the workers
|
||||
request_meshes() {
|
||||
request_meshes(camera: Camera) {
|
||||
for (const chunk of this.chunks.values()) {
|
||||
if (!chunk.dirty || !this.can_mesh(chunk)) {
|
||||
continue;
|
||||
}
|
||||
chunk.dirty = false;
|
||||
chunk.mesh_version += 1;
|
||||
const padded_chunk = this.create_padded_chunk(chunk);
|
||||
// copies, the worker lights the whole 3x3 area
|
||||
const chunks: (Uint32Array | null)[] = [];
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
chunks.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks.slice() ?? null);
|
||||
}
|
||||
}
|
||||
this.workers.post({
|
||||
type: "mesh",
|
||||
chunk_x: chunk.x,
|
||||
chunk_z: chunk.z,
|
||||
version: chunk.mesh_version,
|
||||
padded_chunk,
|
||||
}, [padded_chunk.buffer]);
|
||||
chunks,
|
||||
camera: [camera.x, camera.y, camera.z],
|
||||
}, chunks.filter((blocks) => blocks !== null).map((blocks) => blocks.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
// like sodium, a chunk's translucent quads only change order when the camera crosses one of the
|
||||
// planes they lie on, so that's the only time they get sorted again
|
||||
update_translucent_sorting(camera: Camera) {
|
||||
const position = [camera.x, camera.y, camera.z];
|
||||
for (const chunk of this.chunks.values()) {
|
||||
const sort = chunk.translucent_sort;
|
||||
if (!sort || !crosses_planes(sort.planes, sort.camera, position)) {
|
||||
continue;
|
||||
}
|
||||
sort.camera = position;
|
||||
sort.requested += 1;
|
||||
this.workers.post({
|
||||
type: "sort",
|
||||
chunk_x: chunk.x,
|
||||
chunk_z: chunk.z,
|
||||
version: sort.mesh_version,
|
||||
sort_version: sort.requested,
|
||||
centers: sort.centers,
|
||||
camera: position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#on_worker_message(message: FromChunkWorker) {
|
||||
if (message.type === "generated") {
|
||||
this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills);
|
||||
} else {
|
||||
} else if (message.type === "meshed") {
|
||||
this.#on_meshed(message);
|
||||
} else {
|
||||
this.#on_sorted(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +494,7 @@ export class Dimension extends Component {
|
||||
|
||||
let chunk = this.chunks.get(key);
|
||||
if (chunk) {
|
||||
// a placeholder made by a neighbor's tree, keep its blocks where generation left air
|
||||
// blocks placed here before it generated, keep them where generation left air
|
||||
const existing = chunk.blocks;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (blocks[i] !== AIR) {
|
||||
@@ -341,19 +506,28 @@ export class Dimension extends Component {
|
||||
}
|
||||
chunk.generated = true;
|
||||
chunk.dirty = true;
|
||||
chunk.spills = spills;
|
||||
|
||||
// the same rules as the server (server/game/world.ts): a chunk's own blocks, then what its neighbors'
|
||||
// features put in it, only where it has air. both ways, since the neighbors may have generated first
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
const neighbor_spills = this.get_chunk(cx + dx, cz + dz)?.spills;
|
||||
if (neighbor_spills) {
|
||||
this.#apply_spills(neighbor_spills, chunk);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < spills.length; i += 4) {
|
||||
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
|
||||
}
|
||||
|
||||
for (const [dx, dz] of NEIGHBOR_OFFSETS) {
|
||||
for (const [dx, dz] of ALL_NEIGHBOR_OFFSETS) {
|
||||
const neighbor = this.get_chunk(cx + dx, cz + dz);
|
||||
if (neighbor && neighbor.generated) {
|
||||
neighbor.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
// trees spill into neighboring chunks, so their changes need reapplying too
|
||||
// features spill into neighboring chunks, so their changes need reapplying too
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
this.apply_chunk_changes(cx + dx, cz + dz);
|
||||
@@ -370,16 +544,50 @@ export class Dimension extends Component {
|
||||
|
||||
this.delete_chunk_mesh(chunk);
|
||||
|
||||
chunk.opaque_vertex_buffer = create_vertex_buffer(message.opaque_vertices.subarray(0, message.opaque_count));
|
||||
chunk.opaque_vertex_count = message.opaque_count / 9;
|
||||
for (const layer of RENDER_LAYERS) {
|
||||
const { vertices, quad_count } = message[layer];
|
||||
if (quad_count === 0) {
|
||||
continue;
|
||||
}
|
||||
chunk.meshes[layer] = {
|
||||
vertex_buffer: create_vertex_buffer(vertices.subarray(0, quad_count * FLOATS_PER_QUAD)),
|
||||
quad_count,
|
||||
};
|
||||
}
|
||||
|
||||
chunk.transparent_vertex_buffer = create_vertex_buffer(
|
||||
message.transparent_vertices.subarray(0, message.transparent_count),
|
||||
);
|
||||
chunk.transparent_vertex_count = message.transparent_count / 9;
|
||||
const translucent = message.translucent;
|
||||
if (translucent.quad_count === 0) {
|
||||
return;
|
||||
}
|
||||
chunk.meshes.translucent!.index_buffer = create_index_buffer(translucent.indices);
|
||||
if (translucent.sort_type === "dynamic") {
|
||||
chunk.translucent_sort = {
|
||||
mesh_version: message.version,
|
||||
centers: translucent.centers,
|
||||
planes: translucent.planes,
|
||||
camera: message.camera,
|
||||
requested: 0,
|
||||
applied: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first.
|
||||
#on_sorted(message: Extract<FromChunkWorker, { type: "sorted" }>) {
|
||||
const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
|
||||
const sort = chunk?.translucent_sort;
|
||||
const mesh = chunk?.meshes.translucent;
|
||||
// remeshed since, or a newer sort already came back
|
||||
if (!sort || !mesh || sort.mesh_version !== message.version || message.sort_version <= sort.applied) {
|
||||
return;
|
||||
}
|
||||
sort.applied = message.sort_version;
|
||||
if (mesh.index_buffer) {
|
||||
destroy_buffer(mesh.index_buffer);
|
||||
}
|
||||
mesh.index_buffer = create_index_buffer(message.indices);
|
||||
}
|
||||
|
||||
// blocks a neighbor's features put here, only fill air so the result doesn't depend on which chunk loaded first.
|
||||
// the server builds chunks the same way (server/game/world.ts)
|
||||
#set_block_raw(x: number, y: number, z: number, nid: number) {
|
||||
if (y < 0 || y >= CHUNK_HEIGHT) {
|
||||
@@ -387,45 +595,56 @@ export class Dimension extends Component {
|
||||
}
|
||||
const chunk_x = Math.floor(x / CHUNK_SIZE);
|
||||
const chunk_z = Math.floor(z / CHUNK_SIZE);
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z);
|
||||
const chunk = this.get_chunk(chunk_x, chunk_z);
|
||||
// a chunk that isn't generated yet takes it from our spills when it is
|
||||
if (!chunk?.generated) {
|
||||
return;
|
||||
}
|
||||
const lx = x - chunk_x * CHUNK_SIZE;
|
||||
const lz = z - chunk_z * CHUNK_SIZE;
|
||||
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
|
||||
if (chunk.blocks[index] === AIR) {
|
||||
chunk.blocks[index] = nid;
|
||||
chunk.dirty = true;
|
||||
this.#mark_neighbors_dirty(chunk_x, chunk_z, lx, lz, AIR, nid & ID_MASK);
|
||||
}
|
||||
}
|
||||
|
||||
// the spills that land in chunk
|
||||
#apply_spills(spills: Int32Array, chunk: Chunk) {
|
||||
for (let i = 0; i < spills.length; i += 4) {
|
||||
if (Math.floor(spills[i] / CHUNK_SIZE) === chunk.x && Math.floor(spills[i + 2] / CHUNK_SIZE) === chunk.z) {
|
||||
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete_chunk_mesh(chunk: Chunk) {
|
||||
if (chunk.opaque_vertex_buffer) {
|
||||
destroy_vertex_buffer(chunk.opaque_vertex_buffer);
|
||||
chunk.opaque_vertex_buffer = undefined;
|
||||
}
|
||||
if (chunk.transparent_vertex_buffer) {
|
||||
destroy_vertex_buffer(chunk.transparent_vertex_buffer);
|
||||
chunk.transparent_vertex_buffer = undefined;
|
||||
for (const mesh of Object.values(chunk.meshes)) {
|
||||
destroy_buffer(mesh.vertex_buffer);
|
||||
if (mesh.index_buffer) {
|
||||
destroy_buffer(mesh.index_buffer);
|
||||
}
|
||||
}
|
||||
chunk.meshes = {};
|
||||
chunk.translucent_sort = undefined;
|
||||
}
|
||||
|
||||
get_looked_block(
|
||||
dimension: Dimension,
|
||||
camera: Camera,
|
||||
// the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick
|
||||
pick(
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
max_distance = 6,
|
||||
step = 0.05,
|
||||
): { x: number; y: number; z: number; block: number; face: Faces } | undefined {
|
||||
const yaw = camera.yaw;
|
||||
const pitch = camera.pitch;
|
||||
|
||||
): BlockHitResult | undefined {
|
||||
const cos_pitch = Math.cos(pitch);
|
||||
const dx = -Math.sin(yaw) * cos_pitch;
|
||||
const dy = Math.sin(pitch);
|
||||
const dz = -Math.cos(yaw) * cos_pitch;
|
||||
|
||||
let x = camera.x;
|
||||
let y = camera.y;
|
||||
let z = camera.z;
|
||||
|
||||
let prev_bx = Math.floor(x);
|
||||
let prev_by = Math.floor(y);
|
||||
let prev_bz = Math.floor(z);
|
||||
@@ -446,7 +665,7 @@ export class Dimension extends Component {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = dimension.get_block(bx, by, bz);
|
||||
const block = this.get_block(bx, by, bz);
|
||||
|
||||
if (block && block !== AIR && block !== VOID) {
|
||||
let face: Faces;
|
||||
@@ -475,44 +694,6 @@ export class Dimension extends Component {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
create_padded_chunk(chunk: Chunk) {
|
||||
const size = CHUNK_SIZE + 2;
|
||||
const layer = size * size;
|
||||
const padded = new Uint32Array(layer * CHUNK_HEIGHT);
|
||||
|
||||
// look the 3x3 chunks up once instead of once per border block
|
||||
const around: (Uint32Array | undefined)[] = [];
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
around.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks);
|
||||
}
|
||||
}
|
||||
|
||||
for (let z = -1; z <= CHUNK_SIZE; z++) {
|
||||
const dz = z < 0 ? -1 : z >= CHUNK_SIZE ? 1 : 0;
|
||||
const lz = z - dz * CHUNK_SIZE;
|
||||
for (let x = -1; x <= CHUNK_SIZE; x++) {
|
||||
const dx = x < 0 ? -1 : x >= CHUNK_SIZE ? 1 : 0;
|
||||
const lx = x - dx * CHUNK_SIZE;
|
||||
const source = around[(dz + 1) * 3 + (dx + 1)];
|
||||
const source_index = lz * CHUNK_SIZE + lx;
|
||||
const padded_index = (z + 1) * size + (x + 1);
|
||||
|
||||
if (!source) {
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
padded[y * layer + padded_index] = VOID;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
padded[y * layer + padded_index] = source[y * CHUNK_AREA + source_index] & ID_MASK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return padded;
|
||||
}
|
||||
}
|
||||
|
||||
// functions cant be sent to workers
|
||||
@@ -1,26 +1,36 @@
|
||||
import { AssetManager } from "./assets.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { Client } from "./client.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { Connection, get_player_name } from "./network.ts";
|
||||
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
|
||||
import { Connection } from "./network.ts";
|
||||
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, ServerAddress } from "./handshake.ts";
|
||||
import { confirm_mods } from "./confirm_mods.ts";
|
||||
import { ModLoadError } from "$/common/mod_loader.ts";
|
||||
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
|
||||
import {
|
||||
begin_drawing,
|
||||
canvas,
|
||||
clear_background,
|
||||
end_drawing,
|
||||
init_font,
|
||||
init_window,
|
||||
load_texture,
|
||||
resize_canvas,
|
||||
} from "./renderer/mod.ts";
|
||||
import { is_stopped, show_fatal_error } from "./fatal.ts";
|
||||
import { load_client_mods, set_mods_world } from "./mods.ts";
|
||||
import { load_client_mods, set_mods_client } from "./mods.ts";
|
||||
import type { GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { TitleScreen } from "./gui/title_screen.ts";
|
||||
import { DisconnectedScreen } from "./gui/disconnected_screen.ts";
|
||||
|
||||
// runs whatever is showing every frame: a menu screen before joining (and after leaving), or the game
|
||||
export class ClientLoop {
|
||||
running = false;
|
||||
last_time = 0;
|
||||
world: ClientWorld;
|
||||
client: Client | undefined;
|
||||
screen: GuiScreen | undefined;
|
||||
|
||||
frame_count = 0;
|
||||
last_fps_time = 0;
|
||||
|
||||
constructor(world: ClientWorld) {
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
start() {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
@@ -40,6 +50,23 @@ export class ClientLoop {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
show_screen(screen: GuiScreen) {
|
||||
this.screen?.on_close();
|
||||
this.client = undefined;
|
||||
this.screen = screen;
|
||||
InputManager.set_mouse_grabbed(false);
|
||||
}
|
||||
|
||||
play(connection: Connection) {
|
||||
const client = new Client(connection, (message) => this.show_screen(new DisconnectedScreen(message)));
|
||||
set_mods_client(client);
|
||||
client.chat.add("Connected to the server");
|
||||
this.screen?.on_close();
|
||||
this.screen = undefined;
|
||||
this.client = client;
|
||||
console.log("Game started");
|
||||
}
|
||||
|
||||
loop(time: number) {
|
||||
if (!this.running || is_stopped()) {
|
||||
return;
|
||||
@@ -49,7 +76,12 @@ export class ClientLoop {
|
||||
|
||||
begin_drawing();
|
||||
clear_background(0.69, 0.8, 1, 1.0);
|
||||
this.world.update(delta);
|
||||
if (this.client) {
|
||||
this.client.run_frame(delta);
|
||||
} else if (this.screen) {
|
||||
this.screen.on_tick(delta);
|
||||
this.screen.on_render();
|
||||
}
|
||||
end_drawing();
|
||||
|
||||
this.frame_count += 1;
|
||||
@@ -68,14 +100,70 @@ export class ClientLoop {
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("game") as HTMLCanvasElement;
|
||||
if (!canvas) {
|
||||
// a server's blocks, items and scripts can't be taken back out once loaded, so a failure after that point
|
||||
// can't go back to the title screen to try again, the page has to start over
|
||||
class FailedAfterLoadingMods extends Error {}
|
||||
|
||||
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
|
||||
async function join_server(address: ServerAddress, name: string, status: (text: string) => void): Promise<Connection> {
|
||||
const { socket, welcome } = await connect(address, name);
|
||||
console.log(`Connected to ${address.ws_url}`);
|
||||
|
||||
let loaded_mods = false;
|
||||
try {
|
||||
if (address.cross_origin && !is_trusted(address, welcome)) {
|
||||
status("Waiting for you to accept the server's mods...");
|
||||
if (!await confirm_mods(address, welcome)) {
|
||||
throw new HandshakeError(`You didn't join ${address.base.host}`);
|
||||
}
|
||||
remember_trust(address, welcome);
|
||||
}
|
||||
|
||||
// textures, blocks and items all come from the server's mods, so this happens before anything else
|
||||
status("Downloading the server's mods...");
|
||||
const atlas = await load_atlas(address, welcome.atlas);
|
||||
loaded_mods = true;
|
||||
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
|
||||
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
|
||||
await load_client_mods(welcome.mods, address.base);
|
||||
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
|
||||
|
||||
status("Joining...");
|
||||
const joined = await join(socket);
|
||||
return new Connection(socket, welcome, joined);
|
||||
} catch (e) {
|
||||
socket.close();
|
||||
const message = error_message(e);
|
||||
throw loaded_mods ? new FailedAfterLoadingMods(message) : new HandshakeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
function error_message(e: unknown) {
|
||||
if (e instanceof HandshakeError) {
|
||||
return e.message;
|
||||
}
|
||||
if (e instanceof ModLoadError) {
|
||||
return `Couldn't load the server's mods: ${e.message}`;
|
||||
}
|
||||
return `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
|
||||
}
|
||||
|
||||
const game_canvas = document.getElementById("game") as HTMLCanvasElement;
|
||||
if (!game_canvas) {
|
||||
throw Error("Canvas was not found");
|
||||
}
|
||||
|
||||
await init_window(canvas);
|
||||
try {
|
||||
await init_window(game_canvas);
|
||||
} catch (e) {
|
||||
show_fatal_error(e instanceof Error ? e.message : String(e));
|
||||
throw e;
|
||||
}
|
||||
|
||||
InputManager.initialize(canvas);
|
||||
InputManager.initialize(game_canvas);
|
||||
self.addEventListener("resize", resize_canvas);
|
||||
resize_canvas();
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
|
||||
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
|
||||
|
||||
@@ -90,51 +178,20 @@ await AssetManager.instance.load_all();
|
||||
|
||||
init_font();
|
||||
|
||||
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
|
||||
async function join_server(): Promise<Connection> {
|
||||
const address = server_address();
|
||||
const { socket, welcome } = await connect(address, get_player_name());
|
||||
console.log(`Connected to ${address.ws_url}`);
|
||||
|
||||
try {
|
||||
if (address.cross_origin && !is_trusted(address, welcome)) {
|
||||
if (!await confirm_mods(address, welcome)) {
|
||||
throw new HandshakeError(`You didn't join ${address.base.host}`);
|
||||
const loop = new ClientLoop();
|
||||
loop.show_screen(
|
||||
new TitleScreen(async (address, name, status) => {
|
||||
let connection: Connection;
|
||||
try {
|
||||
connection = await join_server(address, name, status);
|
||||
} catch (e) {
|
||||
if (e instanceof FailedAfterLoadingMods) {
|
||||
loop.show_screen(new DisconnectedScreen(e.message));
|
||||
return;
|
||||
}
|
||||
remember_trust(address, welcome);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// textures, blocks and items all come from the server's mods, so this happens before anything else
|
||||
const atlas = await load_atlas(address, welcome.atlas);
|
||||
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
|
||||
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
|
||||
await load_client_mods(welcome.mods, address.base);
|
||||
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
|
||||
|
||||
const joined = await join(socket);
|
||||
return new Connection(socket, welcome, joined);
|
||||
} catch (e) {
|
||||
socket.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await join_server();
|
||||
const client_world = new ClientWorld(connection);
|
||||
set_mods_world(client_world);
|
||||
client_world.add_chat("Connected to the server");
|
||||
|
||||
const loop = new ClientLoop(client_world);
|
||||
loop.start();
|
||||
|
||||
console.log("Game started");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const message = e instanceof HandshakeError
|
||||
? e.message
|
||||
: e instanceof ModLoadError
|
||||
? `Couldn't load the server's mods: ${e.message}`
|
||||
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
|
||||
show_fatal_error(message);
|
||||
}
|
||||
loop.play(connection);
|
||||
}),
|
||||
);
|
||||
loop.start();
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Entity } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { UIButton } from "./components/ui_components.ts";
|
||||
import { open_about } from "./about.ts";
|
||||
import { start_game } from "./game.ts";
|
||||
import { canvas } from "./renderer/mod.ts";
|
||||
|
||||
export function create_main_menu(world: ClientWorld) {
|
||||
const play_button = new Entity("play");
|
||||
play_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
|
||||
play_button.add(new UIButton("Play", 320, 64, () => start_game(world)));
|
||||
world.add_entity(play_button);
|
||||
|
||||
const about_button = new Entity("aboutbutton");
|
||||
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
|
||||
about_button.add(new UIButton("About", 320, 64, () => open_about()));
|
||||
world.add_entity(about_button);
|
||||
}
|
||||
@@ -6,18 +6,20 @@ import type { ClientContext } from "$/common/mod_api/client.ts";
|
||||
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
|
||||
import type { OreJson } from "$/common/mod_data.ts";
|
||||
import { AIR_ID } from "$/common/protocol.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import type { ClientWorld } from "./client_world.ts";
|
||||
import type { Client } from "./client.ts";
|
||||
import { code_url, fetch_verified } from "./handshake.ts";
|
||||
|
||||
// each loaded mod's credits file, for the credits screen
|
||||
export const mod_credits: { name: string; version: string; text: string }[] = [];
|
||||
|
||||
// what the chunk workers need to generate the same world as the server
|
||||
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
|
||||
|
||||
// set once the world exists, mods can't look at it during setup
|
||||
let world: ClientWorld | undefined;
|
||||
// set once the game is running, mods can't look at it during setup
|
||||
let client: Client | undefined;
|
||||
|
||||
export function set_mods_world(client_world: ClientWorld) {
|
||||
world = client_world;
|
||||
export function set_mods_client(game_client: Client) {
|
||||
client = game_client;
|
||||
}
|
||||
|
||||
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
|
||||
@@ -32,14 +34,21 @@ export async function load_client_mods(listings: ModListing[], base: URL) {
|
||||
throw new ModLoadError(listing.id, (e as Error).message);
|
||||
}
|
||||
};
|
||||
const [data, client, worldgen] = await Promise.all([
|
||||
const [data, client, worldgen, credits] = await Promise.all([
|
||||
get(listing.data, listing.sha256.data, "its data"),
|
||||
get(listing.client, listing.sha256.client, "its client script"),
|
||||
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
|
||||
get(listing.credits, listing.sha256.credits, "its credits"),
|
||||
]);
|
||||
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen };
|
||||
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen, credits };
|
||||
}));
|
||||
|
||||
for (const { listing, credits } of downloads) {
|
||||
if (credits) {
|
||||
mod_credits.push({ name: listing.name, version: listing.version, text: new TextDecoder().decode(credits) });
|
||||
}
|
||||
}
|
||||
|
||||
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
|
||||
|
||||
worldgen_mods.ores = recipes.ores;
|
||||
@@ -65,9 +74,9 @@ function client_context(listing: ModListing): ClientContext {
|
||||
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
|
||||
},
|
||||
});
|
||||
const need_world = () => {
|
||||
if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`);
|
||||
return world;
|
||||
const need_client = () => {
|
||||
if (!client) throw new Error(`[${mod}] the world isn't there yet during setup`);
|
||||
return client;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -78,17 +87,16 @@ function client_context(listing: ModListing): ClientContext {
|
||||
net: not_yet("net", "step 8") as ClientContext["net"],
|
||||
player: {
|
||||
get name() {
|
||||
return need_world().connection.name;
|
||||
return need_client().connection.name;
|
||||
},
|
||||
get position() {
|
||||
const [player] = need_world().get_tag("player")!;
|
||||
const position = player.get(Position)!;
|
||||
return { x: position.x, y: position.y, z: position.z };
|
||||
const { x, y, z } = need_client().player;
|
||||
return { x, y, z };
|
||||
},
|
||||
},
|
||||
world: {
|
||||
get_block(x, y, z) {
|
||||
const nid = need_world().dimension.get_block(x, y, z);
|
||||
const nid = need_client().level.get_block(x, y, z);
|
||||
if (nid === AIR) return AIR_ID;
|
||||
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
|
||||
},
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
|
||||
import { BlockChange, ClientMessage, EntityInfo, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
|
||||
import type { ModListing } from "$/common/mod_loader.ts";
|
||||
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
|
||||
|
||||
export interface RemotePlayer extends PlayerInfo {
|
||||
// where we draw them, eased towards x/y/z so movement isnt choppy
|
||||
display_x: number;
|
||||
display_y: number;
|
||||
display_z: number;
|
||||
color: [number, number, number];
|
||||
}
|
||||
|
||||
export class Connection {
|
||||
#server: ServerSocket;
|
||||
id: string;
|
||||
@@ -19,7 +11,9 @@ export class Connection {
|
||||
initial_changes: BlockChange[];
|
||||
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||
selected_slot: number;
|
||||
players = new Map<string, RemotePlayer>();
|
||||
// who was already there when we joined, they become entities in the level
|
||||
initial_players: PlayerInfo[];
|
||||
initial_entities: EntityInfo[];
|
||||
|
||||
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
|
||||
this.#server = server;
|
||||
@@ -30,12 +24,11 @@ export class Connection {
|
||||
this.initial_changes = join.changes;
|
||||
this.spawn = join.spawn;
|
||||
this.selected_slot = join.selected_slot;
|
||||
for (const player of join.players) {
|
||||
this.add_player(player);
|
||||
}
|
||||
this.initial_players = join.players;
|
||||
this.initial_entities = join.entities;
|
||||
}
|
||||
|
||||
// handled by the network system inside the game loop, not whenever the socket feels like it
|
||||
// handled by the packet listener inside the game loop, not whenever the socket feels like it
|
||||
get incoming(): ServerMessage[] {
|
||||
return this.#server.messages;
|
||||
}
|
||||
@@ -48,41 +41,12 @@ export class Connection {
|
||||
this.#server.send(message);
|
||||
}
|
||||
|
||||
add_player(player: PlayerInfo) {
|
||||
this.players.set(player.id, {
|
||||
...player,
|
||||
display_x: player.x,
|
||||
display_y: player.y,
|
||||
display_z: player.z,
|
||||
color: color_from_name(player.name),
|
||||
});
|
||||
close() {
|
||||
this.#server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function color_from_name(name: string): [number, number, number] {
|
||||
let hash = 0;
|
||||
for (const ch of name) {
|
||||
hash = (hash * 31 + ch.charCodeAt(0)) | 0;
|
||||
}
|
||||
const hue = ((hash % 360) + 360) % 360;
|
||||
// hsl with s=0.6 l=0.6 to rgb
|
||||
const c = 0.48;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = 0.36;
|
||||
const [r, g, b] = hue < 60
|
||||
? [c, x, 0]
|
||||
: hue < 120
|
||||
? [x, c, 0]
|
||||
: hue < 180
|
||||
? [0, c, x]
|
||||
: hue < 240
|
||||
? [0, x, c]
|
||||
: hue < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x];
|
||||
return [r + m, g + m, b + m];
|
||||
}
|
||||
|
||||
export function get_player_name(): string {
|
||||
// what the name field starts as, from ?name=
|
||||
export function default_player_name(): string {
|
||||
return new URLSearchParams(location.search).get("name") ?? "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { KeyCode } from "./input_manager.ts";
|
||||
|
||||
// the player's settings, like minecraft's Options and its key mappings
|
||||
export class Options {
|
||||
render_distance = 6;
|
||||
|
||||
key_forward: KeyCode = "KeyW";
|
||||
key_back: KeyCode = "KeyS";
|
||||
key_left: KeyCode = "KeyA";
|
||||
key_right: KeyCode = "KeyD";
|
||||
key_jump: KeyCode = "Space";
|
||||
key_sprint: KeyCode = "ShiftLeft";
|
||||
key_inventory: KeyCode = "KeyE";
|
||||
// with ctrl it drops the whole stack
|
||||
key_drop: KeyCode = "KeyQ";
|
||||
key_chat: KeyCode = "KeyT";
|
||||
key_debug: KeyCode = "F3";
|
||||
key_fullscreen: KeyCode = "F11";
|
||||
key_hotbar: KeyCode[] = [
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"Digit3",
|
||||
"Digit4",
|
||||
"Digit5",
|
||||
"Digit6",
|
||||
"Digit7",
|
||||
"Digit8",
|
||||
"Digit9",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ServerMessage } from "$/common/protocol.ts";
|
||||
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||
import type { Client } from "./client.ts";
|
||||
import { GuiContainer } from "./gui/gui_container.ts";
|
||||
import { RemotePlayer } from "./entity/remote_player.ts";
|
||||
import { ItemEntity } from "./entity/item_entity.ts";
|
||||
|
||||
// applies what the server sends, like minecraft's ClientPacketListener. messages queue up on the connection and
|
||||
// get handled here once per frame, inside the game loop
|
||||
export class ClientPacketListener {
|
||||
client: Client;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
handle_packets() {
|
||||
const connection = this.client.connection;
|
||||
for (const message of connection.incoming) {
|
||||
this.#handle(message);
|
||||
}
|
||||
connection.incoming.length = 0;
|
||||
}
|
||||
|
||||
#handle(message: ServerMessage) {
|
||||
const client = this.client;
|
||||
const level = client.level;
|
||||
const inventories = client.player.inventories;
|
||||
|
||||
switch (message.type) {
|
||||
case "player_join":
|
||||
level.add_entity(new RemotePlayer(level, message.player));
|
||||
break;
|
||||
case "player_leave":
|
||||
level.remove_entity(message.id);
|
||||
break;
|
||||
case "player_move": {
|
||||
const player = level.entities.get(message.id);
|
||||
if (player instanceof RemotePlayer) {
|
||||
player.lerp_to(message.x, message.y, message.z, message.yaw, message.pitch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "add_entity":
|
||||
level.add_entity(new ItemEntity(level, message.entity));
|
||||
break;
|
||||
case "move_entity": {
|
||||
const entity = level.entities.get(message.id);
|
||||
if (entity instanceof ItemEntity) {
|
||||
entity.lerp_to(message.x, message.y, message.z);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "set_entity_item": {
|
||||
const entity = level.entities.get(message.id);
|
||||
if (entity instanceof ItemEntity) {
|
||||
entity.item = ItemStack.from_data(message.item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove_entity":
|
||||
level.remove_entity(message.id);
|
||||
break;
|
||||
case "take_entity": {
|
||||
const entity = level.entities.get(message.id);
|
||||
const taker = level.entities.get(message.player);
|
||||
if (entity instanceof ItemEntity && taker) {
|
||||
entity.pick_up(taker);
|
||||
} else {
|
||||
level.remove_entity(message.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "set_block":
|
||||
level.record_change(message.x, message.y, message.z, message.id, message.state);
|
||||
level.apply_change(message.x, message.y, message.z, message.id, message.state);
|
||||
break;
|
||||
case "chat":
|
||||
client.chat.add(message.from ? `<${message.from}> ${message.text}` : message.text);
|
||||
break;
|
||||
case "container": {
|
||||
let container = message.container === "screen" ? inventories.screen : inventories[message.container];
|
||||
if (message.container === "screen" && container?.size !== message.items.length) {
|
||||
container = inventories.screen = new Container(message.items.length);
|
||||
}
|
||||
container?.load(message.items);
|
||||
break;
|
||||
}
|
||||
case "cursor":
|
||||
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
|
||||
break;
|
||||
case "open_screen": {
|
||||
// replace anything open locally without telling the server, it just opened this one
|
||||
client.screens.length = 0;
|
||||
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
|
||||
inventories.screen = new Container(size);
|
||||
client.push_screen(
|
||||
new GuiContainer(inventories, (m) => client.connection.send(m), message.layout, message.properties),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "screen_properties": {
|
||||
const screen = client.screen;
|
||||
if (screen instanceof GuiContainer) {
|
||||
screen.properties = message.properties;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "teleport":
|
||||
client.player.set_position(message.x, message.y, message.z);
|
||||
break;
|
||||
case "close_screen":
|
||||
// closed by the server (the block broke), it already put everything back
|
||||
client.screens = client.screens.filter((s) => !(s instanceof GuiContainer));
|
||||
inventories.screen = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
import { Component, Entity } from "$/common/ecs/mod.ts";
|
||||
import { Camera } from "$/client/components/camera.ts";
|
||||
import { ClientInventories } from "./inventory.ts";
|
||||
import { PlayerControls } from "$/client/components/player_controls.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { GuiScreen } from "./gui/gui_screen.ts";
|
||||
import { CollisionCuboid } from "./components/collision.ts";
|
||||
|
||||
export class PlayerComponent extends Component {
|
||||
inventories = new ClientInventories();
|
||||
screens: GuiScreen[] = [];
|
||||
render_distance = 6;
|
||||
|
||||
breaking_block?: { x: number; y: number; z: number };
|
||||
break_progress = 0;
|
||||
break_progress_max = 0;
|
||||
|
||||
pop_screen() {
|
||||
const screen = this.screens.pop();
|
||||
if (screen) {
|
||||
screen.on_close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function create_player(world: ClientWorld) {
|
||||
const player = new Entity("player");
|
||||
const spawn = world.connection.spawn;
|
||||
player.add(new Position(spawn.x, spawn.y, spawn.z));
|
||||
player.add(new Velocity(0, 0, 0));
|
||||
player.add(new PlayerControls());
|
||||
|
||||
const player_component = player.add(new PlayerComponent());
|
||||
player_component.inventories.hotbar_selected = world.connection.selected_slot;
|
||||
const camera = player.add(new Camera());
|
||||
camera.yaw = spawn.yaw;
|
||||
camera.pitch = spawn.pitch;
|
||||
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
|
||||
|
||||
world.add_entity(player);
|
||||
|
||||
const player_hand = new Entity("playerhand");
|
||||
player_hand.add(new Position(0, 0));
|
||||
world.add_entity(player_hand);
|
||||
|
||||
world.add_tag("player", [player, player_hand]);
|
||||
|
||||
return player;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Camera } from "../components/camera.ts";
|
||||
import type { Camera } from "../camera.ts";
|
||||
import { mat4 } from "gl-matrix";
|
||||
import type { RenderLayer } from "$/common/everything_registry.ts";
|
||||
import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
|
||||
|
||||
export let device: GPUDevice;
|
||||
export let canvas: HTMLCanvasElement;
|
||||
@@ -20,9 +22,16 @@ let canvas_format: GPUTextureFormat;
|
||||
|
||||
let pipeline_2d: GPURenderPipeline;
|
||||
let pipeline_3d: GPURenderPipeline;
|
||||
let terrain_pipelines: Record<RenderLayer, GPURenderPipeline>;
|
||||
// 0 1 2 0 2 3 for every quad, shared by all solid and cutout chunk meshes
|
||||
let quad_index_buffer: GPUBuffer | undefined;
|
||||
let quad_index_capacity = 0;
|
||||
let uniform_layout: GPUBindGroupLayout;
|
||||
let texture_layout: GPUBindGroupLayout;
|
||||
let sampler: GPUSampler;
|
||||
// minecraft's lightmap: the color for every block light (x) and sky light (y) pair, see update_lightmap
|
||||
let lightmap: GPUTexture;
|
||||
let lightmap_bind_group: GPUBindGroup;
|
||||
|
||||
const vertex_data = new Float32Array(MAX_SPRITES * VERTS_PER_SPRITE * FLOATS_PER_VERT);
|
||||
let vert_index = 0;
|
||||
@@ -73,8 +82,10 @@ struct Uniforms {
|
||||
|
||||
struct VertexOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) tex_coord: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// centroid: with msaa, pixels on a triangle's edge would otherwise sample outside it, past the
|
||||
// sprite's edge in the atlas, which shows up as dark lines between blocks from far away
|
||||
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
|
||||
@location(1) @interpolate(perspective, centroid) color: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
@@ -90,9 +101,81 @@ fn vs_main(
|
||||
return out;
|
||||
}
|
||||
|
||||
// blended draws. fully clear texels don't write depth, or they would hide what's drawn behind them later
|
||||
@fragment
|
||||
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
return textureSample(texture0, sampler0, in.tex_coord) * in.color;
|
||||
let color = textureSample(texture0, sampler0, in.tex_coord) * in.color;
|
||||
if (color.a < 0.01) {
|
||||
discard;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
`;
|
||||
|
||||
const terrain_shader_src = /* wgsl */ `
|
||||
struct Uniforms {
|
||||
mvp: mat4x4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||
@group(1) @binding(0) var texture0: texture_2d<f32>;
|
||||
@group(1) @binding(1) var sampler0: sampler;
|
||||
@group(2) @binding(0) var lightmap: texture_2d<f32>;
|
||||
@group(2) @binding(1) var lightmap_sampler: sampler;
|
||||
|
||||
struct VertexOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) @interpolate(perspective, centroid) tex_coord: vec2<f32>,
|
||||
// directional shade times ambient occlusion, and alpha
|
||||
@location(1) @interpolate(perspective, centroid) color: vec4<f32>,
|
||||
// block light, sky light, as lightmap coordinates
|
||||
@location(2) @interpolate(perspective, centroid) light: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_terrain(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) tex_coord: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) light: vec2<f32>,
|
||||
) -> VertexOut {
|
||||
var out: VertexOut;
|
||||
out.position = uniforms.mvp * vec4<f32>(position, 1.0);
|
||||
out.tex_coord = tex_coord;
|
||||
out.color = color;
|
||||
out.light = light;
|
||||
return out;
|
||||
}
|
||||
|
||||
fn lit(in: VertexOut) -> vec4<f32> {
|
||||
let texel = textureSample(texture0, sampler0, in.tex_coord);
|
||||
let light = textureSample(lightmap, lightmap_sampler, in.light).rgb;
|
||||
return vec4<f32>(texel.rgb * in.color.rgb * light, texel.a * in.color.a);
|
||||
}
|
||||
|
||||
// no discard, so the gpu can reject hidden fragments before running the shader
|
||||
@fragment
|
||||
fn fs_solid(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(lit(in).rgb, 1.0);
|
||||
}
|
||||
|
||||
// alpha tested instead of blended, so it doesn't need sorting
|
||||
@fragment
|
||||
fn fs_cutout(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
let color = lit(in);
|
||||
if (color.a < 0.1) {
|
||||
discard;
|
||||
}
|
||||
return vec4<f32>(color.rgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_translucent(in: VertexOut) -> @location(0) vec4<f32> {
|
||||
let color = lit(in);
|
||||
if (color.a < 0.01) {
|
||||
discard;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -136,6 +219,47 @@ export async function init_window(canvas_element: HTMLCanvasElement) {
|
||||
create_uniform_buffer(64);
|
||||
|
||||
create_white_texture();
|
||||
create_lightmap();
|
||||
}
|
||||
|
||||
// how bright the sky is, 1 at noon. only the lightmap changes, the chunk meshes stay the same
|
||||
export function update_lightmap(daylight = 1) {
|
||||
// minecraft's LightTexture, with its default brightness setting and no flicker
|
||||
const gamma = 0.5;
|
||||
const brightness = (level: number) => {
|
||||
const f = level / 15;
|
||||
return f / (4 - 3 * f);
|
||||
};
|
||||
const lerp = (from: number, to: number, t: number) => from + (to - from) * t;
|
||||
const clamp = (value: number) => Math.min(1, Math.max(0, value));
|
||||
const not_gamma = (value: number) => 1 - (1 - value) ** 4;
|
||||
|
||||
const sky_factor = daylight * 0.95 + 0.05;
|
||||
// sky light turns blue as it gets dark
|
||||
const sky_color = [lerp(daylight, 1, 0.35), lerp(daylight, 1, 0.35), 1];
|
||||
const block_boost = 1.5;
|
||||
|
||||
const pixels = new Uint8Array(16 * 16 * 4);
|
||||
for (let sky = 0; sky < 16; sky++) {
|
||||
for (let block = 0; block < 16; block++) {
|
||||
const s = brightness(sky) * sky_factor;
|
||||
// block light is warm, it loses blue and green faster as it dims
|
||||
const b = brightness(block) * block_boost;
|
||||
const color = [
|
||||
b + sky_color[0] * s,
|
||||
b * ((b * 0.6 + 0.4) * 0.6 + 0.4) + sky_color[1] * s,
|
||||
b * (b * b * 0.6 + 0.4) + sky_color[2] * s,
|
||||
].map((c) => clamp(lerp(c, 0.75, 0.04)))
|
||||
.map((c) => clamp(lerp(lerp(c, not_gamma(c), gamma), 0.75, 0.04)));
|
||||
|
||||
const i = (sky * 16 + block) * 4;
|
||||
pixels[i] = Math.round(color[0] * 255);
|
||||
pixels[i + 1] = Math.round(color[1] * 255);
|
||||
pixels[i + 2] = Math.round(color[2] * 255);
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
device.queue.writeTexture({ texture: lightmap }, pixels, { bytesPerRow: 16 * 4 }, [16, 16]);
|
||||
}
|
||||
|
||||
export function begin_drawing() {
|
||||
@@ -251,8 +375,34 @@ export function flush_batch() {
|
||||
vert_index = 0;
|
||||
}
|
||||
|
||||
export function flush_buffer(buffer: GPUBuffer, draw_count: number) {
|
||||
draw(buffer, 0, draw_count);
|
||||
// draws a chunk mesh made of quads (4 vertices each). without an index buffer the quads are drawn in order
|
||||
export function draw_terrain(
|
||||
layer: RenderLayer,
|
||||
vertex_buffer: GPUBuffer,
|
||||
quad_count: number,
|
||||
index_buffer?: GPUBuffer,
|
||||
) {
|
||||
if (!current_texture || quad_count === 0) {
|
||||
return;
|
||||
}
|
||||
if (!index_buffer) {
|
||||
ensure_quad_indices(quad_count);
|
||||
index_buffer = quad_index_buffer!;
|
||||
}
|
||||
|
||||
const render_pass = ensure_pass();
|
||||
const pipeline = terrain_pipelines[layer];
|
||||
if (current_pipeline !== pipeline) {
|
||||
render_pass.setPipeline(pipeline);
|
||||
current_pipeline = pipeline;
|
||||
}
|
||||
|
||||
render_pass.setBindGroup(0, uniform_bind_group, [uniform_slot * UNIFORM_SLOT_SIZE]);
|
||||
render_pass.setBindGroup(1, get_texture_bind_group(current_texture));
|
||||
render_pass.setBindGroup(2, lightmap_bind_group);
|
||||
render_pass.setVertexBuffer(0, vertex_buffer);
|
||||
render_pass.setIndexBuffer(index_buffer, "uint32");
|
||||
render_pass.drawIndexed(quad_count * 6);
|
||||
}
|
||||
|
||||
export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
|
||||
@@ -264,7 +414,16 @@ export function create_vertex_buffer(vertices: Float32Array): GPUBuffer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function destroy_vertex_buffer(buffer: GPUBuffer) {
|
||||
export function create_index_buffer(indices: Uint32Array): GPUBuffer {
|
||||
const buffer = device.createBuffer({
|
||||
size: Math.max(4, indices.byteLength),
|
||||
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
device.queue.writeBuffer(buffer, 0, indices);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function destroy_buffer(buffer: GPUBuffer) {
|
||||
// it might be used by a draw thats not submitted yet
|
||||
if (encoder) {
|
||||
pending_destroy.push(buffer);
|
||||
@@ -375,6 +534,28 @@ export function push_quad_vertices(
|
||||
|
||||
// internal
|
||||
|
||||
function ensure_quad_indices(quad_count: number) {
|
||||
if (quad_count <= quad_index_capacity) {
|
||||
return;
|
||||
}
|
||||
if (quad_index_buffer) {
|
||||
destroy_buffer(quad_index_buffer);
|
||||
}
|
||||
quad_index_capacity = Math.max(quad_count, quad_index_capacity * 2, 16384);
|
||||
const indices = new Uint32Array(quad_index_capacity * 6);
|
||||
for (let q = 0; q < quad_index_capacity; q++) {
|
||||
const v = q * 4;
|
||||
const i = q * 6;
|
||||
indices[i] = v;
|
||||
indices[i + 1] = v + 1;
|
||||
indices[i + 2] = v + 2;
|
||||
indices[i + 3] = v;
|
||||
indices[i + 4] = v + 2;
|
||||
indices[i + 5] = v + 3;
|
||||
}
|
||||
quad_index_buffer = create_index_buffer(indices);
|
||||
}
|
||||
|
||||
function draw(buffer: GPUBuffer, offset: number, vertex_count: number) {
|
||||
if (!current_texture || vertex_count === 0) {
|
||||
return;
|
||||
@@ -554,21 +735,61 @@ function create_pipelines() {
|
||||
depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: false, depthCompare: "always" },
|
||||
});
|
||||
|
||||
const primitive_3d: GPUPrimitiveState = { topology: "triangle-list", cullMode: "back", frontFace: "ccw" };
|
||||
const depth_3d: GPUDepthStencilState = {
|
||||
format: DEPTH_FORMAT,
|
||||
depthWriteEnabled: true,
|
||||
depthCompare: "less-equal",
|
||||
// same as the old polygonOffset(1, 1)
|
||||
depthBias: 1,
|
||||
depthBiasSlopeScale: 1,
|
||||
};
|
||||
|
||||
pipeline_3d = device.createRenderPipeline({
|
||||
layout,
|
||||
vertex,
|
||||
fragment,
|
||||
multisample,
|
||||
primitive: { topology: "triangle-list", cullMode: "back", frontFace: "ccw" },
|
||||
depthStencil: {
|
||||
format: DEPTH_FORMAT,
|
||||
depthWriteEnabled: true,
|
||||
depthCompare: "less-equal",
|
||||
// same as the old polygonOffset(1, 1)
|
||||
depthBias: 1,
|
||||
depthBiasSlopeScale: 1,
|
||||
},
|
||||
primitive: primitive_3d,
|
||||
depthStencil: depth_3d,
|
||||
});
|
||||
|
||||
const terrain_module = device.createShaderModule({ code: terrain_shader_src });
|
||||
const terrain_layout = device.createPipelineLayout({
|
||||
bindGroupLayouts: [uniform_layout, texture_layout, texture_layout],
|
||||
});
|
||||
const terrain_vertex: GPUVertexState = {
|
||||
module: terrain_module,
|
||||
entryPoint: "vs_terrain",
|
||||
buffers: [{
|
||||
arrayStride: TERRAIN_VERTEX_FLOATS * 4,
|
||||
attributes: [
|
||||
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
||||
{ shaderLocation: 1, offset: 12, format: "float32x2" },
|
||||
{ shaderLocation: 2, offset: 20, format: "float32x4" },
|
||||
{ shaderLocation: 3, offset: 36, format: "float32x2" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
// like sodium and vanilla: solid and cutout don't blend, translucent blends and still writes depth
|
||||
// since it's drawn sorted back to front
|
||||
const terrain_pipeline = (entryPoint: string, blend?: GPUBlendState) =>
|
||||
device.createRenderPipeline({
|
||||
layout: terrain_layout,
|
||||
vertex: terrain_vertex,
|
||||
fragment: { module: terrain_module, entryPoint, targets: [{ format: canvas_format, blend }] },
|
||||
multisample,
|
||||
primitive: primitive_3d,
|
||||
depthStencil: depth_3d,
|
||||
});
|
||||
terrain_pipelines = {
|
||||
solid: terrain_pipeline("fs_solid"),
|
||||
cutout: terrain_pipeline("fs_cutout"),
|
||||
translucent: terrain_pipeline("fs_translucent", {
|
||||
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
||||
alpha: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function create_stream_buffer(size: number) {
|
||||
@@ -601,6 +822,25 @@ function get_texture_bind_group(texture: GPUTexture) {
|
||||
return bind_group;
|
||||
}
|
||||
|
||||
function create_lightmap() {
|
||||
lightmap = create_texture(16, 16);
|
||||
// linear, so light fades smoothly between levels across a face like in minecraft
|
||||
const lightmap_sampler = device.createSampler({
|
||||
magFilter: "linear",
|
||||
minFilter: "linear",
|
||||
addressModeU: "clamp-to-edge",
|
||||
addressModeV: "clamp-to-edge",
|
||||
});
|
||||
lightmap_bind_group = device.createBindGroup({
|
||||
layout: texture_layout,
|
||||
entries: [
|
||||
{ binding: 0, resource: lightmap.createView() },
|
||||
{ binding: 1, resource: lightmap_sampler },
|
||||
],
|
||||
});
|
||||
update_lightmap();
|
||||
}
|
||||
|
||||
function create_white_texture() {
|
||||
const tex = create_texture(1, 1);
|
||||
device.queue.writeTexture({ texture: tex }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Client } from "$/client/client.ts";
|
||||
import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts";
|
||||
import { Hud } from "$/client/gui/hud.ts";
|
||||
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
|
||||
import { LevelRenderer } from "./level_renderer.ts";
|
||||
|
||||
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
|
||||
export class GameRenderer {
|
||||
level_renderer = new LevelRenderer();
|
||||
hud = new Hud();
|
||||
debug_overlay = new DebugOverlay();
|
||||
|
||||
// partial_tick is how far this frame is between the last tick and the next, entities are drawn in between
|
||||
render(client: Client, partial_tick: number) {
|
||||
const camera = client.camera;
|
||||
camera.setup(client.player, partial_tick);
|
||||
|
||||
begin_mode_3d(camera);
|
||||
this.level_renderer.render_opaque(client.level, camera);
|
||||
this.level_renderer.render_destroy_progress(client.game_mode);
|
||||
this.level_renderer.render_entities(client.level, client.player, partial_tick);
|
||||
this.level_renderer.render_translucent(client.level, camera);
|
||||
end_mode_3d();
|
||||
|
||||
this.hud.render(client);
|
||||
client.screen?.on_render();
|
||||
if (client.debugging) {
|
||||
this.debug_overlay.render(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import type { ItemEntity } from "$/client/entity/item_entity.ts";
|
||||
import { push_vertex, Texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
// how big items on the ground are drawn, minecraft's ground transform
|
||||
const BLOCK_SCALE = 0.25;
|
||||
const ITEM_SCALE = 0.5;
|
||||
// keeps texture lookups off the sprite's edge
|
||||
const UV_PAD = 0.5;
|
||||
|
||||
// each face's corners in drawing order (counter clockwise from outside) on a unit cube, with its shade.
|
||||
// top, bottom, front, back, left, right, like the chunk mesher
|
||||
const CUBE_FACES = [
|
||||
{ corners: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]], shade: 1.0, texture: "top" },
|
||||
{ corners: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], shade: 0.5, texture: "bottom" },
|
||||
{ corners: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], shade: 0.8, texture: "front" },
|
||||
{ corners: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]], shade: 0.8, texture: "side" },
|
||||
{ corners: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], shade: 0.6, texture: "side" },
|
||||
{ corners: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]], shade: 0.6, texture: "side" },
|
||||
] as const;
|
||||
// sprite end each corner gets, u then v
|
||||
const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
|
||||
|
||||
// where the extra copies of a bigger stack sit, as fractions of the model's size
|
||||
const COPY_OFFSETS = [[0, 0, 0], [0.35, 0.2, -0.25], [-0.3, 0.4, 0.2], [0.15, 0.6, 0.35], [-0.2, 0.8, -0.3]];
|
||||
|
||||
// minecraft draws more copies of the model the bigger the stack is
|
||||
function copies(amount: number) {
|
||||
if (amount > 48) return 5;
|
||||
if (amount > 32) return 4;
|
||||
if (amount > 16) return 3;
|
||||
if (amount > 1) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// the atlas has to be the current texture
|
||||
export function render_item_entity(entity: ItemEntity, partial_tick: number) {
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id);
|
||||
const block_info = item_info?.block_id
|
||||
? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
|
||||
: undefined;
|
||||
|
||||
// spinning and bobbing, like minecraft's ItemEntityRenderer
|
||||
const time = entity.age + partial_tick;
|
||||
const angle = time / 20 + entity.bob_offset;
|
||||
const bob = Math.sin(time / 10 + entity.bob_offset) * 0.1 + 0.1;
|
||||
const { x, y, z } = entity.render_position(partial_tick);
|
||||
|
||||
const scale = block_info ? BLOCK_SCALE : ITEM_SCALE;
|
||||
for (let i = 0; i < copies(entity.item.amount); i++) {
|
||||
const [ox, oy, oz] = COPY_OFFSETS[i];
|
||||
const base = { x: x + ox * scale, y: y + bob + oy * scale * 0.5, z: z + oz * scale };
|
||||
if (block_info) {
|
||||
push_block(atlas, block_info, base, scale, angle);
|
||||
} else {
|
||||
push_sprite(atlas, item_texture(entity, item_info), base, scale, angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a small cube turned around its middle
|
||||
function push_block(
|
||||
atlas: Texture,
|
||||
block: BlockRegistry,
|
||||
base: { x: number; y: number; z: number },
|
||||
size: number,
|
||||
angle: number,
|
||||
) {
|
||||
const sin = Math.sin(angle);
|
||||
const cos = Math.cos(angle);
|
||||
const corner = (cx: number, cy: number, cz: number) => {
|
||||
const lx = (cx - 0.5) * size;
|
||||
const lz = (cz - 0.5) * size;
|
||||
return [base.x + lx * cos - lz * sin, base.y + cy * size, base.z + lx * sin + lz * cos];
|
||||
};
|
||||
|
||||
for (const face of CUBE_FACES) {
|
||||
const region = get_sprite_region(block_face_texture(block, face.texture));
|
||||
push_textured_quad(atlas, region, face.corners.map(([cx, cy, cz]) => corner(cx, cy, cz)), face.shade);
|
||||
}
|
||||
}
|
||||
|
||||
// a flat sprite standing up and turning, drawn from both sides
|
||||
function push_sprite(
|
||||
atlas: Texture,
|
||||
texture_id: string,
|
||||
base: { x: number; y: number; z: number },
|
||||
size: number,
|
||||
angle: number,
|
||||
) {
|
||||
const region = get_sprite_region(texture_id);
|
||||
const dx = Math.cos(angle) * size / 2;
|
||||
const dz = Math.sin(angle) * size / 2;
|
||||
const bottom = base.y;
|
||||
const top = base.y + size;
|
||||
const front = [
|
||||
[base.x - dx, bottom, base.z - dz],
|
||||
[base.x + dx, bottom, base.z + dz],
|
||||
[base.x + dx, top, base.z + dz],
|
||||
[base.x - dx, top, base.z - dz],
|
||||
];
|
||||
push_textured_quad(atlas, region, front, 1);
|
||||
// the back is the same quad the other way round, mirrored so it isn't drawn backwards
|
||||
push_textured_quad(atlas, region, [front[1], front[0], front[3], front[2]], 1);
|
||||
}
|
||||
|
||||
function push_textured_quad(atlas: Texture, region: { x: number; y: number }, corners: number[][], shade: number) {
|
||||
const u0 = (region.x * TEXTURE_SIZE + UV_PAD) / atlas.width;
|
||||
const v0 = (region.y * TEXTURE_SIZE + UV_PAD) / atlas.height;
|
||||
const u1 = ((region.x + 1) * TEXTURE_SIZE - UV_PAD) / atlas.width;
|
||||
const v1 = ((region.y + 1) * TEXTURE_SIZE - UV_PAD) / atlas.height;
|
||||
|
||||
for (const i of [0, 1, 2, 0, 2, 3]) {
|
||||
const [px, py, pz] = corners[i];
|
||||
const [cu, cv] = CORNER_UVS[i];
|
||||
push_vertex(px, py, pz, cu ? u1 : u0, cv ? v1 : v0, shade, shade, shade, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function block_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
|
||||
const textures = block.textures;
|
||||
if (typeof textures === "string") {
|
||||
return textures;
|
||||
}
|
||||
if ("top" in textures) {
|
||||
return face === "top" ? textures.top : face === "bottom" ? textures.bottom : textures.side;
|
||||
}
|
||||
return face === "front" ? textures.front : textures.side;
|
||||
}
|
||||
|
||||
function item_texture(entity: ItemEntity, item_info: ItemRegistry | undefined) {
|
||||
const texture_id = item_info?.texture_id;
|
||||
if (typeof texture_id === "function") {
|
||||
return texture_id(entity.item);
|
||||
}
|
||||
return texture_id ?? "engine:missing";
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { CHUNK_SIZE, ClientLevel } from "$/client/level/client_level.ts";
|
||||
import { Camera } from "$/client/camera.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import type { MultiPlayerGameMode } from "$/client/game_mode.ts";
|
||||
import type { Entity } from "$/client/entity/entity.ts";
|
||||
import { RemotePlayer } from "$/client/entity/remote_player.ts";
|
||||
import { ItemEntity } from "$/client/entity/item_entity.ts";
|
||||
import { render_item_entity } from "./item_renderer.ts";
|
||||
import {
|
||||
draw_terrain,
|
||||
flush_batch,
|
||||
push_back_face,
|
||||
push_bottom_face,
|
||||
push_box,
|
||||
push_front_face,
|
||||
push_left_face,
|
||||
push_right_face,
|
||||
push_top_face,
|
||||
set_current_texture,
|
||||
Texture,
|
||||
white_tex,
|
||||
} from "$/client/renderer/mod.ts";
|
||||
|
||||
const BREAKING_FACES = [
|
||||
push_back_face,
|
||||
push_bottom_face,
|
||||
push_front_face,
|
||||
push_left_face,
|
||||
push_right_face,
|
||||
push_top_face,
|
||||
];
|
||||
|
||||
// draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities
|
||||
export class LevelRenderer {
|
||||
// solid and cutout terrain, drawn before entities
|
||||
render_opaque(level: ClientLevel, camera: Camera) {
|
||||
level.request_meshes(camera);
|
||||
level.update_translucent_sorting(camera);
|
||||
|
||||
set_current_texture(level.image.tex);
|
||||
|
||||
for (const chunk of level.chunks.values()) {
|
||||
const mesh = chunk.meshes.solid;
|
||||
if (mesh) {
|
||||
draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of level.chunks.values()) {
|
||||
const mesh = chunk.meshes.cutout;
|
||||
if (mesh) {
|
||||
draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// translucent terrain, drawn after entities so they show through water and glass.
|
||||
// chunks go back to front, and each chunk's quads are already sorted back to front
|
||||
render_translucent(level: ClientLevel, camera: Camera) {
|
||||
const distance_sq = (x: number, z: number) => {
|
||||
const dx = (x + 0.5) * CHUNK_SIZE - camera.x;
|
||||
const dz = (z + 0.5) * CHUNK_SIZE - camera.z;
|
||||
return dx * dx + dz * dz;
|
||||
};
|
||||
const chunks = [...level.chunks.values()]
|
||||
.filter((chunk) => chunk.meshes.translucent)
|
||||
.map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) }))
|
||||
.sort((a, b) => b.distance - a.distance);
|
||||
|
||||
set_current_texture(level.image.tex);
|
||||
|
||||
for (const { mesh } of chunks) {
|
||||
draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// every entity but the one the camera is in
|
||||
render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) {
|
||||
flush_batch();
|
||||
set_current_texture(white_tex!);
|
||||
|
||||
for (const entity of level.entities.values()) {
|
||||
if (entity !== camera_entity && entity instanceof RemotePlayer) {
|
||||
render_player(entity, partial_tick);
|
||||
}
|
||||
}
|
||||
flush_batch();
|
||||
|
||||
set_current_texture(level.image.tex);
|
||||
for (const entity of level.entities.values()) {
|
||||
if (entity instanceof ItemEntity) {
|
||||
render_item_entity(entity, partial_tick);
|
||||
}
|
||||
}
|
||||
flush_batch();
|
||||
}
|
||||
|
||||
// the cracks on the block being broken
|
||||
render_destroy_progress(game_mode: MultiPlayerGameMode) {
|
||||
const block = game_mode.destroy_pos;
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = Math.max(0, Math.min(1, game_mode.destroy_progress / game_mode.destroy_time));
|
||||
const stage = Math.round(progress * 8);
|
||||
if (Number.isNaN(stage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tex = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
const region = get_sprite_region(`engine:break_${stage}`);
|
||||
for (const push_face of BREAKING_FACES) {
|
||||
push_face(
|
||||
tex,
|
||||
block.x,
|
||||
block.y,
|
||||
block.z,
|
||||
region.x * TEXTURE_SIZE,
|
||||
region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a box body and head in the player's color
|
||||
function render_player(player: RemotePlayer, partial_tick: number) {
|
||||
const [r, g, b] = player.color;
|
||||
const { x, y, z } = player.render_position(partial_tick);
|
||||
|
||||
// body
|
||||
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
|
||||
// head
|
||||
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { CollisionCuboid } from "$/client/components/collision.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
import { Dimension } from "../components/dimension.ts";
|
||||
|
||||
export class CollisionSystem extends System {
|
||||
override update(world: ClientWorld, delta: number): void {
|
||||
for (const entity of world.get_entities()) {
|
||||
const position = entity.get(Position);
|
||||
const velocity = entity.get(Velocity);
|
||||
const cuboid = entity.get(CollisionCuboid);
|
||||
|
||||
if (!position || !velocity || !cuboid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
velocity.vy += cuboid.gravity * delta;
|
||||
|
||||
let new_position = position.clone();
|
||||
|
||||
new_position.x += velocity.vx * delta;
|
||||
|
||||
let collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
|
||||
|
||||
cuboid.colliding_x = collisions.x;
|
||||
if (collisions.x !== 0) {
|
||||
velocity.vx = 0;
|
||||
}
|
||||
|
||||
new_position = position.clone();
|
||||
|
||||
new_position.y += velocity.vy * delta;
|
||||
|
||||
collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
|
||||
|
||||
cuboid.colliding_y = collisions.y;
|
||||
if (collisions.y !== 0) {
|
||||
velocity.vy = 0;
|
||||
}
|
||||
|
||||
new_position = position.clone();
|
||||
|
||||
new_position.z += velocity.vz * delta;
|
||||
|
||||
collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
|
||||
|
||||
cuboid.colliding_z = collisions.z;
|
||||
if (collisions.z !== 0) {
|
||||
velocity.vz = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check_collision(
|
||||
position: Position,
|
||||
velocity: Velocity,
|
||||
cuboid: CollisionCuboid,
|
||||
dimension: Dimension,
|
||||
): { x: number; y: number; z: number } {
|
||||
const collisions = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const min_x = Math.floor(position.x - cuboid.width / 2);
|
||||
const max_x = Math.floor(position.x + cuboid.width / 2);
|
||||
const min_y = Math.floor(position.y);
|
||||
const max_y = Math.floor(position.y + cuboid.height);
|
||||
const min_z = Math.floor(position.z - cuboid.depth / 2);
|
||||
const max_z = Math.floor(position.z + cuboid.depth / 2);
|
||||
|
||||
for (let x = min_x; x <= max_x; x++) {
|
||||
for (let y = min_y; y <= max_y; y++) {
|
||||
for (let z = min_z; z <= max_z; z++) {
|
||||
const block = dimension.get_block(x, y, z);
|
||||
if (block && block !== 0) {
|
||||
if (velocity.vx > 0) {
|
||||
collisions.x = -1;
|
||||
}
|
||||
if (velocity.vx < 0) {
|
||||
collisions.x = 1;
|
||||
}
|
||||
if (velocity.vy > 0) {
|
||||
collisions.y = -1;
|
||||
}
|
||||
if (velocity.vy < 0) {
|
||||
collisions.y = 1;
|
||||
}
|
||||
if (velocity.vz > 0) {
|
||||
collisions.z = -1;
|
||||
}
|
||||
if (velocity.vz < 0) {
|
||||
collisions.z = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collisions;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { ClientWorld } from "$/client/client_world.ts";
|
||||
import { DebugUI } from "$/client/debug_ui.ts";
|
||||
|
||||
export class DebugSystem extends System {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
update(world: ClientWorld, _delta: number): void {
|
||||
if (!world.debugging) {
|
||||
return;
|
||||
}
|
||||
|
||||
DebugUI.begin("Entities", 10, 10, 300);
|
||||
|
||||
for (const entity of world.get_entities()) {
|
||||
if (DebugUI.collapsing_header("Entity - " + entity.id)) {
|
||||
for (const component of entity.get_all()) {
|
||||
if (DebugUI.collapsing_header(`${component.constructor.name}##${entity.id}`)) {
|
||||
this.render_component(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DebugUI.end();
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
render_component(component: any) {
|
||||
for (const key in component) {
|
||||
if (key === "__component") {
|
||||
continue;
|
||||
}
|
||||
if (typeof component[key] === "number") {
|
||||
component[key] = DebugUI.float_input(
|
||||
key,
|
||||
component[key],
|
||||
);
|
||||
} else if (typeof component[key] === "string") {
|
||||
component[key] = DebugUI.text_input(
|
||||
key,
|
||||
component[key],
|
||||
);
|
||||
} else if (typeof component[key] === "boolean") {
|
||||
component[key] = DebugUI.checkbox(
|
||||
key,
|
||||
component[key],
|
||||
);
|
||||
} else if (Array.isArray(component[key])) {
|
||||
DebugUI.text(`${key}: ${JSON.stringify(component[key].slice(0, 10))}`);
|
||||
} else {
|
||||
DebugUI.text(`${key}: ${JSON.stringify(component[key])}`);
|
||||
}
|
||||
DebugUI.separator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { Camera } from "../components/camera.ts";
|
||||
import { Container, ItemStack } from "$/common/inventory.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { GuiContainer } from "../gui/gui_container.ts";
|
||||
import { show_fatal_error } from "../fatal.ts";
|
||||
|
||||
const MOVE_SEND_INTERVAL = 1 / 10;
|
||||
const REMOTE_PLAYER_SMOOTHING = 12;
|
||||
|
||||
export class NetworkSystem extends System {
|
||||
move_timer = 0;
|
||||
|
||||
update(world: ClientWorld, delta: number): void {
|
||||
const connection = world.connection;
|
||||
const [local_player] = world.get_tag("player")!;
|
||||
const player_component = local_player.get(PlayerComponent)!;
|
||||
const inventories = player_component.inventories;
|
||||
|
||||
for (const message of connection.incoming) {
|
||||
switch (message.type) {
|
||||
case "player_join":
|
||||
connection.add_player(message.player);
|
||||
break;
|
||||
case "player_leave":
|
||||
connection.players.delete(message.id);
|
||||
break;
|
||||
case "player_move": {
|
||||
const player = connection.players.get(message.id);
|
||||
if (player) {
|
||||
player.x = message.x;
|
||||
player.y = message.y;
|
||||
player.z = message.z;
|
||||
player.yaw = message.yaw;
|
||||
player.pitch = message.pitch;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "set_block":
|
||||
world.dimension.record_change(message.x, message.y, message.z, message.id, message.state);
|
||||
world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state);
|
||||
break;
|
||||
case "chat":
|
||||
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
|
||||
break;
|
||||
case "container": {
|
||||
let container = message.container === "screen"
|
||||
? inventories.screen
|
||||
: inventories[message.container];
|
||||
if (message.container === "screen" && container?.size !== message.items.length) {
|
||||
container = inventories.screen = new Container(message.items.length);
|
||||
}
|
||||
container?.load(message.items);
|
||||
break;
|
||||
}
|
||||
case "cursor":
|
||||
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
|
||||
break;
|
||||
case "open_screen": {
|
||||
// replace anything open locally without telling the server, it just opened this one
|
||||
player_component.screens.length = 0;
|
||||
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
|
||||
inventories.screen = new Container(size);
|
||||
player_component.screens.push(
|
||||
new GuiContainer(inventories, (m) => connection.send(m), message.layout, message.properties),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "screen_properties": {
|
||||
const screen = player_component.screens.at(-1);
|
||||
if (screen instanceof GuiContainer) {
|
||||
screen.properties = message.properties;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "teleport": {
|
||||
const position = local_player.get(Position)!;
|
||||
position.x = message.x;
|
||||
position.y = message.y;
|
||||
position.z = message.z;
|
||||
break;
|
||||
}
|
||||
case "close_screen":
|
||||
// closed by the server (the block broke), it already put everything back
|
||||
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
|
||||
inventories.screen = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
connection.incoming.length = 0;
|
||||
|
||||
if (connection.closed) {
|
||||
show_fatal_error("Lost connection to the server");
|
||||
return;
|
||||
}
|
||||
|
||||
const t = Math.min(1, delta * REMOTE_PLAYER_SMOOTHING);
|
||||
for (const player of connection.players.values()) {
|
||||
player.display_x += (player.x - player.display_x) * t;
|
||||
player.display_y += (player.y - player.display_y) * t;
|
||||
player.display_z += (player.z - player.display_z) * t;
|
||||
}
|
||||
|
||||
this.move_timer += delta;
|
||||
if (this.move_timer >= MOVE_SEND_INTERVAL) {
|
||||
this.move_timer = 0;
|
||||
const [player] = world.get_tag("player")!;
|
||||
const position = player.get(Position)!;
|
||||
const camera = player.get(Camera)!;
|
||||
connection.send({
|
||||
type: "move",
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
z: position.z,
|
||||
yaw: camera.yaw,
|
||||
pitch: camera.pitch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
import { InputManager } from "../input_manager.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { PlayerControls } from "../components/player_controls.ts";
|
||||
import { Camera } from "../components/camera.ts";
|
||||
import { Position } from "../../common/components/position.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
|
||||
import { CollisionCuboid } from "../components/collision.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { AIR, FACE_OFFSETS } from "$/common/constants.ts";
|
||||
import { ClientMessage } from "$/common/protocol.ts";
|
||||
import { GuiChat } from "../gui/gui_chat.ts";
|
||||
import { GuiInventoryScreen } from "../gui/gui_screen.ts";
|
||||
|
||||
export class PlayerControlsSystem extends System {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
update(world: ClientWorld, delta: number): void {
|
||||
const [player] = world.get_tag("player")!;
|
||||
const velocity = player.get(Velocity)!;
|
||||
const controls = player.get(PlayerControls)!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
const position = player.get(Position)!;
|
||||
const camera = player.get(Camera)!;
|
||||
const send = (message: ClientMessage) => world.connection.send(message);
|
||||
|
||||
if (player_component.screens.length === 0) {
|
||||
let input_x = 0;
|
||||
let input_z = 0;
|
||||
|
||||
if (InputManager.is_key_down(controls.move_left)) {
|
||||
input_x -= 1;
|
||||
}
|
||||
if (InputManager.is_key_down(controls.move_right)) {
|
||||
input_x += 1;
|
||||
}
|
||||
if (InputManager.is_key_down(controls.move_forward)) {
|
||||
input_z -= 1;
|
||||
}
|
||||
if (InputManager.is_key_down(controls.move_backwards)) {
|
||||
input_z += 1;
|
||||
}
|
||||
|
||||
const size = Math.hypot(input_x, input_z);
|
||||
if (size > 0) {
|
||||
input_x /= size;
|
||||
input_z /= size;
|
||||
}
|
||||
|
||||
const sin = Math.sin(camera.yaw);
|
||||
const cos = Math.cos(camera.yaw);
|
||||
|
||||
const forwardX = sin;
|
||||
const forwardZ = cos;
|
||||
|
||||
const rightX = cos;
|
||||
const rightZ = -sin;
|
||||
|
||||
const speed_modifier = InputManager.is_key_down(controls.sprint_key) ? 1.75 : 1;
|
||||
|
||||
velocity.vx = (forwardX * input_z + rightX * input_x) * controls.move_speed * speed_modifier;
|
||||
velocity.vz = (forwardZ * input_z + rightZ * input_x) * controls.move_speed * speed_modifier;
|
||||
|
||||
const cuboid = player.get(CollisionCuboid);
|
||||
|
||||
if (InputManager.is_key_down("Space") && cuboid?.colliding_y === 1) {
|
||||
velocity.vy += controls.jump_force;
|
||||
}
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed(controls.open_inventory)) {
|
||||
if (player_component.screens.length === 0) {
|
||||
player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send));
|
||||
} else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
|
||||
player_component.pop_screen();
|
||||
}
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed(controls.open_chat)) {
|
||||
if (player_component.screens.length === 0) {
|
||||
player_component.screens.push(new GuiChat(world));
|
||||
}
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("Escape")) {
|
||||
player_component.pop_screen();
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed(controls.open_debug)) {
|
||||
world.debugging = !world.debugging;
|
||||
}
|
||||
|
||||
if (InputManager.is_key_pressed("F11")) {
|
||||
InputManager.toggle_fullscreen();
|
||||
}
|
||||
|
||||
camera.x = position.x;
|
||||
camera.y = position.y + 1.69;
|
||||
camera.z = position.z;
|
||||
|
||||
if (InputManager.is_mouse_grabbed()) {
|
||||
const mouse_delta = InputManager.get_mouse_delta();
|
||||
camera.yaw += -mouse_delta.x * 0.001;
|
||||
camera.pitch += -mouse_delta.y * 0.001;
|
||||
|
||||
const limit = Math.PI / 2 - 0.01;
|
||||
camera.pitch = Math.max(-limit, Math.min(limit, camera.pitch));
|
||||
}
|
||||
|
||||
InputManager.set_mouse_grabbed(player_component.screens.length === 0);
|
||||
|
||||
if (InputManager.is_mouse_down(0) && player_component.screens.length === 0) {
|
||||
player_component.breaking_block = { x: 0, y: 9999, z: 0 };
|
||||
} else {
|
||||
player_component.breaking_block = undefined;
|
||||
player_component.break_progress_max = 0;
|
||||
player_component.break_progress = 0;
|
||||
}
|
||||
|
||||
const block = world.dimension.get_looked_block(world.dimension, camera);
|
||||
const inventories = player_component.inventories;
|
||||
const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected);
|
||||
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", hotbar_slot.type_id ?? "");
|
||||
|
||||
if (block && player_component.screens.length === 0) {
|
||||
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
|
||||
if (InputManager.is_mouse_pressed(0)) {
|
||||
// for mods' on_click, breaking itself is timed here and sent when done
|
||||
send({ type: "hit_block", x: block.x, y: block.y, z: block.z });
|
||||
}
|
||||
if (player_component.breaking_block) {
|
||||
player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
|
||||
player_component.break_progress_max = block_info.toughness ?? 9999;
|
||||
let multiplier = 1;
|
||||
if (holding_item_info?.tool_type === block_info.tool_to_break) {
|
||||
multiplier *= 2;
|
||||
}
|
||||
player_component.break_progress += delta * multiplier;
|
||||
if (player_component.break_progress >= player_component.break_progress_max) {
|
||||
// show it right away, the server decides drops and corrects us if it disagrees
|
||||
world.dimension.break_block(block.x, block.y, block.z);
|
||||
send({ type: "break_block", x: block.x, y: block.y, z: block.z });
|
||||
player_component.break_progress_max = 0;
|
||||
player_component.break_progress = 0;
|
||||
}
|
||||
} else if (InputManager.is_mouse_pressed(2)) {
|
||||
send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
|
||||
|
||||
// guess that it places the held block, unless the block does something when used
|
||||
const offset = FACE_OFFSETS[block.face];
|
||||
const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
|
||||
const target_id = world.dimension.get_block(target.x, target.y, target.z);
|
||||
const replaceable = target_id === AIR ||
|
||||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
|
||||
// items with components might do something else on the server, like on_use
|
||||
const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id;
|
||||
if (!block_info.interactive && place_id && replaceable) {
|
||||
world.dimension.add_block({ ...target, id: place_id });
|
||||
hotbar_slot.amount = hotbar_slot.amount! - 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
player_component.breaking_block = undefined;
|
||||
player_component.break_progress_max = 0;
|
||||
player_component.break_progress = 0;
|
||||
if (!block && player_component.screens.length === 0 && InputManager.is_mouse_pressed(2)) {
|
||||
send({ type: "use_item" });
|
||||
}
|
||||
}
|
||||
|
||||
if (player_component.screens.length === 0) {
|
||||
const previous = inventories.hotbar_selected;
|
||||
const scroll = InputManager.get_wheel_delta();
|
||||
if (scroll > 0) {
|
||||
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
|
||||
} else if (scroll < 0) {
|
||||
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
|
||||
}
|
||||
|
||||
const hotbar_keys = [
|
||||
controls.hotbar_1,
|
||||
controls.hotbar_2,
|
||||
controls.hotbar_3,
|
||||
controls.hotbar_4,
|
||||
controls.hotbar_5,
|
||||
controls.hotbar_6,
|
||||
controls.hotbar_7,
|
||||
controls.hotbar_8,
|
||||
controls.hotbar_9,
|
||||
];
|
||||
const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
|
||||
if (pressed !== -1) {
|
||||
inventories.hotbar_selected = pressed;
|
||||
}
|
||||
|
||||
if (inventories.hotbar_selected !== previous) {
|
||||
send({ type: "select_slot", slot: inventories.hotbar_selected });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
|
||||
import { Dimension } from "../components/dimension.ts";
|
||||
import { Camera } from "$/client/components/camera.ts";
|
||||
|
||||
import { render_animated_sprite, render_sprite } from "./rendering/sprites.ts";
|
||||
import { render_dimension } from "./rendering/dimension.ts";
|
||||
import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts";
|
||||
import { PlayerComponent } from "../player.ts";
|
||||
import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { render_chat_log, render_remote_players } from "./rendering/network.ts";
|
||||
|
||||
export class RenderSystem extends System {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
update(world: ClientWorld, _delta: number): void {
|
||||
const camera_entity = world.get_entities().values().find((e) => e.get(Camera));
|
||||
const camera = camera_entity?.get(Camera);
|
||||
|
||||
if (!camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
begin_mode_3d(camera);
|
||||
|
||||
for (const entity of world.get_entities()) {
|
||||
const dimension = entity.get(Dimension);
|
||||
|
||||
if (dimension) {
|
||||
render_dimension(dimension, camera);
|
||||
}
|
||||
|
||||
const player_component = entity.get(PlayerComponent);
|
||||
if (player_component) {
|
||||
render_player_breaking(player_component);
|
||||
}
|
||||
}
|
||||
|
||||
if (world.connection) {
|
||||
render_remote_players(world.connection);
|
||||
}
|
||||
|
||||
end_mode_3d();
|
||||
|
||||
for (const entity of world.get_entities()) {
|
||||
const position = entity.get(Position);
|
||||
|
||||
const sprite = entity.get(Sprite);
|
||||
if (position && sprite) {
|
||||
render_sprite(sprite, position);
|
||||
}
|
||||
|
||||
const animated_sprite = entity.get(AnimatedSprite);
|
||||
if (position && animated_sprite) {
|
||||
render_animated_sprite(animated_sprite, position);
|
||||
}
|
||||
|
||||
const player_component = entity.get(PlayerComponent);
|
||||
if (player_component) {
|
||||
render_player_hotbar(player_component.inventories);
|
||||
render_player_crosshair();
|
||||
}
|
||||
}
|
||||
|
||||
render_chat_log(world, false);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Chunk, Dimension } from "$/client/components/dimension.ts";
|
||||
import { Camera } from "$/client/components/camera.ts";
|
||||
import { flush_buffer, set_current_texture } from "$/client/renderer/mod.ts";
|
||||
|
||||
export function render_dimension(dimension: Dimension, _camera: Camera) {
|
||||
dimension.request_meshes();
|
||||
|
||||
set_current_texture(dimension.image.tex);
|
||||
|
||||
for (const chunk of dimension.chunks.values()) {
|
||||
render_chunk_opaque(chunk);
|
||||
}
|
||||
|
||||
for (const chunk of dimension.chunks.values()) {
|
||||
render_chunk_transparent(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function render_chunk_opaque(chunk: Chunk) {
|
||||
if (!chunk.opaque_vertex_buffer || !chunk.opaque_vertex_count) {
|
||||
return;
|
||||
}
|
||||
|
||||
flush_buffer(chunk.opaque_vertex_buffer, chunk.opaque_vertex_count);
|
||||
}
|
||||
|
||||
function render_chunk_transparent(chunk: Chunk) {
|
||||
if (!chunk.transparent_vertex_buffer || !chunk.transparent_vertex_count) {
|
||||
return;
|
||||
}
|
||||
|
||||
flush_buffer(chunk.transparent_vertex_buffer, chunk.transparent_vertex_count);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ClientWorld } from "$/client/client_world.ts";
|
||||
import { Connection } from "$/client/network.ts";
|
||||
import {
|
||||
canvas,
|
||||
draw_rect,
|
||||
draw_text,
|
||||
flush_batch,
|
||||
push_box,
|
||||
set_current_texture,
|
||||
white_tex,
|
||||
} from "$/client/renderer/mod.ts";
|
||||
|
||||
const CHAT_LINE_HEIGHT = 24;
|
||||
const CHAT_VISIBLE_SECONDS = 10;
|
||||
const CHAT_MAX_LINES = 10;
|
||||
|
||||
export function render_remote_players(connection: Connection) {
|
||||
if (connection.players.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flush_batch();
|
||||
set_current_texture(white_tex!);
|
||||
|
||||
for (const player of connection.players.values()) {
|
||||
const [r, g, b] = player.color;
|
||||
const x = player.display_x;
|
||||
const y = player.display_y;
|
||||
const z = player.display_z;
|
||||
|
||||
// body
|
||||
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
|
||||
// head
|
||||
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
|
||||
}
|
||||
|
||||
flush_batch();
|
||||
}
|
||||
|
||||
// draws the chat log above the bottom left corner, `all` shows old messages too (when the chat is open)
|
||||
export function render_chat_log(world: ClientWorld, all: boolean, bottom = canvas.height - 100) {
|
||||
const now = performance.now();
|
||||
const lines = world.chat_log
|
||||
.filter((line) => all || now - line.time < CHAT_VISIBLE_SECONDS * 1000)
|
||||
.slice(-CHAT_MAX_LINES);
|
||||
|
||||
let y = bottom - lines.length * CHAT_LINE_HEIGHT;
|
||||
for (const line of lines) {
|
||||
draw_rect(0, y, 600, CHAT_LINE_HEIGHT, [0, 0, 0, 0.4]);
|
||||
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
|
||||
y += CHAT_LINE_HEIGHT;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import { AssetManager } from "$/client/assets.ts";
|
||||
import { ClientInventories } from "../../inventory.ts";
|
||||
import { draw_item, draw_nine_slice } from "./render_utils.ts";
|
||||
import {
|
||||
canvas,
|
||||
draw_rect_stroke,
|
||||
push_back_face,
|
||||
push_bottom_face,
|
||||
push_front_face,
|
||||
push_left_face,
|
||||
push_right_face,
|
||||
push_top_face,
|
||||
Texture,
|
||||
} from "$/client/renderer/mod.ts";
|
||||
import { PlayerComponent } from "../../player.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
|
||||
const PADDING = 10;
|
||||
|
||||
export function render_player_hotbar(inventories: ClientInventories) {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
|
||||
const hotbar_height = PADDING * 2 + SLOT_SIZE;
|
||||
|
||||
const x = canvas.width / 2 - hotbar_width / 2;
|
||||
const y = canvas.height - hotbar_height;
|
||||
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
160,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
x,
|
||||
y,
|
||||
hotbar_width,
|
||||
hotbar_height,
|
||||
);
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
|
||||
inventories.hotbar_selected === index ? 16 : 0,
|
||||
16,
|
||||
16,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
x + PADDING + index * SLOT_SIZE,
|
||||
y + PADDING,
|
||||
SLOT_SIZE,
|
||||
SLOT_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const item = inventories.inventory.get_item(index);
|
||||
if (item) {
|
||||
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function render_player_crosshair() {
|
||||
const CROSSHAIR_SIZE = 8;
|
||||
draw_rect_stroke(
|
||||
(canvas.width - CROSSHAIR_SIZE) / 2,
|
||||
(canvas.height - CROSSHAIR_SIZE) / 2,
|
||||
CROSSHAIR_SIZE,
|
||||
CROSSHAIR_SIZE,
|
||||
[0, 0, 0, 0.6],
|
||||
);
|
||||
}
|
||||
|
||||
const FACE_FUNCTIONS = [
|
||||
push_back_face,
|
||||
push_bottom_face,
|
||||
push_front_face,
|
||||
push_left_face,
|
||||
push_right_face,
|
||||
push_top_face,
|
||||
];
|
||||
export function render_player_breaking(player_component: PlayerComponent) {
|
||||
const block = player_component.breaking_block;
|
||||
if (block) {
|
||||
const tex = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
|
||||
const progress = Math.max(
|
||||
0,
|
||||
Math.min(1, player_component.break_progress / player_component.break_progress_max),
|
||||
);
|
||||
const break_sprite = Math.round(progress * 8);
|
||||
|
||||
if (Number.isNaN(break_sprite)) {
|
||||
return;
|
||||
}
|
||||
const region = get_sprite_region(`engine:break_${break_sprite}`);
|
||||
|
||||
for (const fn of FACE_FUNCTIONS) {
|
||||
fn(
|
||||
tex,
|
||||
block.x,
|
||||
block.y,
|
||||
block.z,
|
||||
region.x * TEXTURE_SIZE,
|
||||
region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
|
||||
import { draw_texture_region } from "$/client/renderer/mod.ts";
|
||||
|
||||
export function render_sprite(sprite: Sprite, position: Position) {
|
||||
draw_texture_region(
|
||||
sprite.image,
|
||||
sprite.source_x,
|
||||
sprite.source_y,
|
||||
sprite.source_width,
|
||||
sprite.source_height,
|
||||
position.x,
|
||||
position.y,
|
||||
sprite.width,
|
||||
sprite.height,
|
||||
sprite.flip_x,
|
||||
sprite.flip_y,
|
||||
);
|
||||
}
|
||||
|
||||
export function render_animated_sprite(
|
||||
animated_sprite: AnimatedSprite,
|
||||
position: Position,
|
||||
) {
|
||||
const current_animation = animated_sprite.states[animated_sprite.current_state];
|
||||
if (!current_animation) {
|
||||
console.error(`Missing animation for state ${animated_sprite.current_state}`);
|
||||
return;
|
||||
}
|
||||
|
||||
draw_texture_region(
|
||||
animated_sprite.image,
|
||||
current_animation.source_x[animated_sprite.animation_frame],
|
||||
current_animation.source_y[animated_sprite.animation_frame],
|
||||
current_animation.source_width,
|
||||
current_animation.source_height,
|
||||
position.x,
|
||||
position.y,
|
||||
animated_sprite.width,
|
||||
animated_sprite.height,
|
||||
animated_sprite.flip_x,
|
||||
animated_sprite.flip_y,
|
||||
);
|
||||
|
||||
animated_sprite.timer += 1;
|
||||
if (animated_sprite.timer >= current_animation.duration) {
|
||||
animated_sprite.timer = 0;
|
||||
animated_sprite.animation_frame += 1;
|
||||
|
||||
if (animated_sprite.animation_frame >= current_animation.source_x.length) {
|
||||
animated_sprite.animation_frame = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { point_inside_rec } from "$/common/utils.ts";
|
||||
import { ClientWorld } from "$/client/client_world.ts";
|
||||
import { UIButton } from "$/client/components/ui_components.ts";
|
||||
import { InputManager } from "$/client/input_manager.ts";
|
||||
|
||||
export class UIInteractionSystem implements System {
|
||||
update(world: ClientWorld, _delta: number) {
|
||||
const mouse = InputManager.get_mouse_position();
|
||||
|
||||
for (const entity of world.get_entities()) {
|
||||
const position = entity.get(Position);
|
||||
const button = entity.get(UIButton);
|
||||
|
||||
if (position && button) {
|
||||
const hovered = point_inside_rec(mouse.x, mouse.y, position.x, position.y, button.width, button.height);
|
||||
button.hovered = hovered;
|
||||
if (hovered && InputManager.is_mouse_pressed(0)) {
|
||||
InputManager.consume_mouse(0);
|
||||
button.on_click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { ClientWorld } from "$/client/client_world.ts";
|
||||
import { UIButton } from "$/client/components/ui_components.ts";
|
||||
import { draw_nine_slice } from "./rendering/render_utils.ts";
|
||||
import { AssetManager } from "../assets.ts";
|
||||
import { draw_text, Texture } from "../renderer/mod.ts";
|
||||
|
||||
const SCALE = 4;
|
||||
|
||||
export class UIRenderSystem implements System {
|
||||
constructor() {}
|
||||
|
||||
update(world: ClientWorld, _delta: number) {
|
||||
const ui = AssetManager.instance.get<Texture>("bworld:ui");
|
||||
|
||||
for (const entity of world.get_entities()) {
|
||||
const position = entity.get(Position);
|
||||
const button = entity.get(UIButton);
|
||||
|
||||
if (position && button) {
|
||||
draw_nine_slice(
|
||||
ui,
|
||||
16 * (button.hovered ? 14 : 11),
|
||||
16 * (button.hovered ? 1 : 0),
|
||||
16,
|
||||
16,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
position.x / SCALE,
|
||||
position.y / SCALE,
|
||||
button.width / SCALE,
|
||||
button.height / SCALE,
|
||||
);
|
||||
// const measure = measure_text(ctx, button.text, 1.25);
|
||||
draw_text(
|
||||
button.text,
|
||||
(position.x / SCALE) + (button.width / SCALE / 2) - (100 / 2),
|
||||
position.y / SCALE + (button.height / SCALE / 2),
|
||||
//1.5,
|
||||
//"white",
|
||||
//"middle",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { System } from "$/common/ecs/mod.ts";
|
||||
import { ClientWorld } from "../client_world.ts";
|
||||
import { Position } from "../../common/components/position.ts";
|
||||
import { CHUNK_SIZE } from "../components/dimension.ts";
|
||||
import { PlayerComponent } from "$/client/player.ts";
|
||||
|
||||
export class WorldGenerationSystem extends System {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
update(world: ClientWorld, _delta: number): void {
|
||||
const [player] = world.get_tag("player")!;
|
||||
const position = player.get(Position)!;
|
||||
const player_component = player.get(PlayerComponent)!;
|
||||
const dimension = world.dimension;
|
||||
|
||||
const player_chunk_x = Math.floor(position.x / CHUNK_SIZE);
|
||||
const player_chunk_z = Math.floor(position.z / CHUNK_SIZE);
|
||||
|
||||
// one extra ring gets generated so the edge of the render distance has neighbors to mesh against
|
||||
const load_distance = player_component.render_distance + 1;
|
||||
const out_of_range = (x: number, z: number) =>
|
||||
Math.max(Math.abs(x - player_chunk_x), Math.abs(z - player_chunk_z)) > load_distance;
|
||||
|
||||
// collect first, deleting from the map while iterating it skips entries
|
||||
const to_unload = [];
|
||||
for (const chunk of dimension.chunks.values()) {
|
||||
if (out_of_range(chunk.x, chunk.z)) {
|
||||
to_unload.push(chunk);
|
||||
}
|
||||
}
|
||||
for (const chunk of to_unload) {
|
||||
dimension.unload_chunk(chunk.x, chunk.z);
|
||||
}
|
||||
for (const pending of [...dimension.pending_generation.values()]) {
|
||||
if (out_of_range(pending.x, pending.z)) {
|
||||
dimension.cancel_chunk_request(pending.x, pending.z);
|
||||
}
|
||||
}
|
||||
|
||||
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
|
||||
const max_in_flight = dimension.workers.size * 2;
|
||||
if (dimension.pending_generation.size >= max_in_flight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing: { x: number; z: number; distance: number }[] = [];
|
||||
for (let x = player_chunk_x - load_distance; x <= player_chunk_x + load_distance; x += 1) {
|
||||
for (let z = player_chunk_z - load_distance; z <= player_chunk_z + load_distance; z += 1) {
|
||||
if (!dimension.is_generated(x, z) && !dimension.is_generating(x, z)) {
|
||||
const dx = x - player_chunk_x;
|
||||
const dz = z - player_chunk_z;
|
||||
missing.push({ x, z, distance: dx * dx + dz * dz });
|
||||
}
|
||||
}
|
||||
}
|
||||
missing.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const chunk of missing.slice(0, max_in_flight - dimension.pending_generation.size)) {
|
||||
dimension.request_chunk(chunk.x, chunk.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { BlockRegistry } from "$/common/everything_registry.ts";
|
||||
import type { SpriteRegion } from "$/common/constants.ts";
|
||||
import type { OreJson } from "$/common/mod_data.ts";
|
||||
import type { SortType } from "./translucent_sort.ts";
|
||||
|
||||
// messages between the main thread and the chunk workers
|
||||
|
||||
// position 3, uv 2, color 4 (directional shade and ambient occlusion, alpha), lightmap coordinates 2
|
||||
export const TERRAIN_VERTEX_FLOATS = 11;
|
||||
|
||||
export type ToChunkWorker =
|
||||
| {
|
||||
type: "init";
|
||||
@@ -17,7 +21,31 @@ export type ToChunkWorker =
|
||||
ores: OreJson[];
|
||||
}
|
||||
| { type: "generate"; chunk_x: number; chunk_z: number; seed: string }
|
||||
| { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array };
|
||||
| {
|
||||
type: "mesh";
|
||||
chunk_x: number;
|
||||
chunk_z: number;
|
||||
version: number;
|
||||
// copies of the blocks of the 3x3 chunks around it, going +x then +z from -x -z, for lighting
|
||||
chunks: (Uint32Array | null)[];
|
||||
// where the camera is, to sort the translucent quads
|
||||
camera: number[];
|
||||
}
|
||||
| {
|
||||
type: "sort";
|
||||
chunk_x: number;
|
||||
chunk_z: number;
|
||||
version: number;
|
||||
sort_version: number;
|
||||
centers: Float32Array;
|
||||
camera: number[];
|
||||
};
|
||||
|
||||
// 4 vertices per quad
|
||||
export interface LayerMesh {
|
||||
vertices: Float32Array;
|
||||
quad_count: number;
|
||||
}
|
||||
|
||||
export type FromChunkWorker =
|
||||
| {
|
||||
@@ -25,7 +53,7 @@ export type FromChunkWorker =
|
||||
chunk_x: number;
|
||||
chunk_z: number;
|
||||
blocks: Uint32Array;
|
||||
// blocks that landed in other chunks (tree leaves), flattened as x, y, z, numeric id
|
||||
// blocks that landed in other chunks (from features like a mod's trees), flattened as x, y, z, numeric id
|
||||
spills: Int32Array;
|
||||
}
|
||||
| {
|
||||
@@ -33,8 +61,23 @@ export type FromChunkWorker =
|
||||
chunk_x: number;
|
||||
chunk_z: number;
|
||||
version: number;
|
||||
opaque_vertices: Float32Array;
|
||||
opaque_count: number;
|
||||
transparent_vertices: Float32Array;
|
||||
transparent_count: number;
|
||||
solid: LayerMesh;
|
||||
cutout: LayerMesh;
|
||||
translucent: LayerMesh & {
|
||||
// sorted back to front for the camera the mesh was requested with
|
||||
indices: Uint32Array;
|
||||
sort_type: SortType;
|
||||
// what resorting needs, see translucent_sort.ts
|
||||
centers: Float32Array;
|
||||
planes: [Float32Array, Float32Array, Float32Array];
|
||||
};
|
||||
camera: number[];
|
||||
}
|
||||
| {
|
||||
type: "sorted";
|
||||
chunk_x: number;
|
||||
chunk_z: number;
|
||||
version: number;
|
||||
sort_version: number;
|
||||
indices: Uint32Array;
|
||||
};
|
||||
|
||||
@@ -1,271 +1,91 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { BlockRegistry } from "$/common/everything_registry.ts";
|
||||
import type { SpriteRegion } from "$/common/constants.ts";
|
||||
import {
|
||||
block_light_emission,
|
||||
block_light_opacity,
|
||||
type BlockRegistry,
|
||||
type RenderLayer,
|
||||
} from "$/common/everything_registry.ts";
|
||||
import { AIR, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK, type SpriteRegion, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import type { Texture } from "../renderer/types.ts";
|
||||
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
|
||||
import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS, type ToChunkWorker } from "./chunk_messages.ts";
|
||||
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
|
||||
import { load_worldgen } from "$/common/worldgen_loader.ts";
|
||||
import { default_block_value } from "$/common/utils.ts";
|
||||
|
||||
const pad = 0.5;
|
||||
|
||||
function push_vertex(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
px: number,
|
||||
py: number,
|
||||
pz: number,
|
||||
u: number,
|
||||
v: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number,
|
||||
) {
|
||||
vertices[i++] = px;
|
||||
vertices[i++] = py;
|
||||
vertices[i++] = pz;
|
||||
vertices[i++] = u;
|
||||
vertices[i++] = v;
|
||||
vertices[i++] = r;
|
||||
vertices[i++] = g;
|
||||
vertices[i++] = b;
|
||||
vertices[i++] = a;
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export function push_front_face(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
texture: Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const x2 = x + 1;
|
||||
const y2 = y + 1;
|
||||
const z2 = z + 1;
|
||||
|
||||
const u0 = (sx + pad) / texture.width;
|
||||
const v0 = (sy + pad) / texture.height;
|
||||
const u1 = (sx + sw - pad) / texture.width;
|
||||
const v1 = (sy + sh - pad) / texture.height;
|
||||
|
||||
i = push_vertex(vertices, i, x, y, z2, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y, z2, u1, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z2, u1, v0, r, g, b, a);
|
||||
|
||||
i = push_vertex(vertices, i, x, y, z2, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z2, u1, v0, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z2, u0, v0, r, g, b, a);
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export function push_back_face(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
texture: Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const x2 = x + 1;
|
||||
const y2 = y + 1;
|
||||
|
||||
const u0 = (sx + pad) / texture.width;
|
||||
const v0 = (sy + pad) / texture.height;
|
||||
const u1 = (sx + sw - pad) / texture.width;
|
||||
const v1 = (sy + sh - pad) / texture.height;
|
||||
|
||||
i = push_vertex(vertices, i, x2, y, z, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y, z, u1, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z, u1, v0, r, g, b, a);
|
||||
|
||||
i = push_vertex(vertices, i, x2, y, z, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z, u1, v0, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z, u0, v0, r, g, b, a);
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export function push_left_face(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
texture: Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const y2 = y + 1;
|
||||
const z2 = z + 1;
|
||||
|
||||
const u0 = (sx + pad) / texture.width;
|
||||
const v0 = (sy + pad) / texture.height;
|
||||
const u1 = (sx + sw - pad) / texture.width;
|
||||
const v1 = (sy + sh - pad) / texture.height;
|
||||
|
||||
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y, z2, u1, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z2, u1, v0, r, g, b, a);
|
||||
|
||||
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z2, u1, v0, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z, u0, v0, r, g, b, a);
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export function push_right_face(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
texture: Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const x2 = x + 1;
|
||||
const y2 = y + 1;
|
||||
const z2 = z + 1;
|
||||
|
||||
const u0 = (sx + pad) / texture.width;
|
||||
const v0 = (sy + pad) / texture.height;
|
||||
const u1 = (sx + sw - pad) / texture.width;
|
||||
const v1 = (sy + sh - pad) / texture.height;
|
||||
|
||||
i = push_vertex(vertices, i, x2, y, z2, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y, z, u1, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
|
||||
|
||||
i = push_vertex(vertices, i, x2, y, z2, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z2, u0, v0, r, g, b, a);
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export function push_top_face(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
texture: Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const x2 = x + 1;
|
||||
const z2 = z + 1;
|
||||
const y2 = y + 1;
|
||||
|
||||
const u0 = (sx + pad) / texture.width;
|
||||
const v0 = (sy + pad) / texture.height;
|
||||
const u1 = (sx + sw - pad) / texture.width;
|
||||
const v1 = (sy + sh - pad) / texture.height;
|
||||
|
||||
i = push_vertex(vertices, i, x, y2, z2, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z2, u1, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
|
||||
|
||||
i = push_vertex(vertices, i, x, y2, z2, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y2, z, u1, v0, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y2, z, u0, v0, r, g, b, a);
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export function push_bottom_face(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
texture: Texture,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
r = 1,
|
||||
g = 1,
|
||||
b = 1,
|
||||
a = 1,
|
||||
) {
|
||||
const x2 = x + 1;
|
||||
const z2 = z + 1;
|
||||
|
||||
const u0 = (sx + pad) / texture.width;
|
||||
const v0 = (sy + pad) / texture.height;
|
||||
const u1 = (sx + sw - pad) / texture.width;
|
||||
const v1 = (sy + sh - pad) / texture.height;
|
||||
|
||||
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y, z, u1, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y, z2, u1, v0, r, g, b, a);
|
||||
|
||||
i = push_vertex(vertices, i, x, y, z, u0, v1, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x2, y, z2, u1, v0, r, g, b, a);
|
||||
i = push_vertex(vertices, i, x, y, z2, u0, v0, r, g, b, a);
|
||||
|
||||
return i;
|
||||
}
|
||||
import {
|
||||
choose_sort_type,
|
||||
FACE_NORMALS,
|
||||
quad_indices,
|
||||
quad_planes,
|
||||
sort_by_distance,
|
||||
sort_quads,
|
||||
} from "./translucent_sort.ts";
|
||||
import {
|
||||
LightRegion,
|
||||
type LightTables,
|
||||
region_block,
|
||||
region_block_light,
|
||||
REGION_LAYER,
|
||||
REGION_SIZE,
|
||||
region_sky,
|
||||
REGION_VOID,
|
||||
} from "./lighting.ts";
|
||||
|
||||
type TexturesInfo = Record<string, SpriteRegion>;
|
||||
|
||||
const CHUNK_SIZE = 16;
|
||||
const CHUNK_HEIGHT = 128;
|
||||
const TEXTURE_SIZE = 16;
|
||||
const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
|
||||
// keeps texture lookups off the sprite's edge
|
||||
const UV_PAD = 0.5;
|
||||
|
||||
const FACE_PUSHING_FUNCTIONS = {
|
||||
top: push_top_face,
|
||||
bottom: push_bottom_face,
|
||||
front: push_front_face,
|
||||
back: push_back_face,
|
||||
left: push_left_face,
|
||||
right: push_right_face,
|
||||
} as const;
|
||||
// same order as FACE_NORMALS in translucent_sort.ts
|
||||
const FACES = ["top", "bottom", "front", "back", "left", "right"] as const;
|
||||
// each face's corners in drawing order (counter clockwise from outside), as offsets from the block's corner
|
||||
const FACE_CORNERS = [
|
||||
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
|
||||
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
|
||||
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
|
||||
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
|
||||
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
|
||||
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
|
||||
] as const;
|
||||
// which end of the sprite each corner gets, u then v (0 = start, 1 = end)
|
||||
const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
|
||||
// minecraft's shading by direction, so faces stay apart even in flat light
|
||||
const FACE_SHADE = [1.0, 0.5, 0.8, 0.8, 0.6, 0.6];
|
||||
|
||||
// for each face corner, the two cells beside the cell in front of the face that touch that corner,
|
||||
// as [index offset, y offset] for each. the cell touching both is at the sum of them
|
||||
const CORNER_SIDES = FACE_CORNERS.map((corners, face) =>
|
||||
corners.map((corner) => {
|
||||
const sides: number[] = [];
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
if (FACE_NORMALS[face][axis] !== 0) continue;
|
||||
const d = corner[axis] * 2 - 1;
|
||||
sides.push(axis === 0 ? d : axis === 1 ? d * REGION_LAYER : d * REGION_SIZE, axis === 1 ? d : 0);
|
||||
}
|
||||
return sides;
|
||||
})
|
||||
);
|
||||
|
||||
const SOLID = 0;
|
||||
const CUTOUT = 1;
|
||||
const TRANSLUCENT = 2;
|
||||
const LAYER_IDS: Record<RenderLayer, number> = { solid: SOLID, cutout: CUTOUT, translucent: TRANSLUCENT };
|
||||
|
||||
let blocks_registry: BlockRegistry[] = [];
|
||||
let block_ids: Record<string, number> = {};
|
||||
// by numeric id, looked up for every face. like sodium's light data cache, everything the mesher asks about a
|
||||
// block is worked out once instead of per face
|
||||
const TABLE_SIZE = ID_MASK + 1;
|
||||
const block_layers = new Uint8Array(TABLE_SIZE);
|
||||
const block_cull_same = new Uint8Array(TABLE_SIZE);
|
||||
// darkens the corners it touches (minecraft's ambient occlusion), blocks with a full collision box do
|
||||
const block_occludes = new Uint8Array(TABLE_SIZE);
|
||||
// light can come around a corner past it
|
||||
const block_lets_light_by = new Uint8Array(TABLE_SIZE);
|
||||
const light_tables: LightTables = { opacity: new Uint8Array(TABLE_SIZE), emission: new Uint8Array(TABLE_SIZE) };
|
||||
const region = new LightRegion();
|
||||
|
||||
let textures_info: TexturesInfo = {};
|
||||
let image: Texture;
|
||||
let worldgen: WorldgenSetup | undefined;
|
||||
@@ -280,6 +100,7 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
|
||||
case "init":
|
||||
blocks_registry = message.blocks_registry;
|
||||
block_ids = message.block_ids;
|
||||
build_block_tables();
|
||||
textures_info = message.textures_info;
|
||||
image = message.image as Texture;
|
||||
default_values = blocks_registry.map((block, nid) => default_block_value(nid, block));
|
||||
@@ -292,29 +113,44 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
|
||||
generate(message.chunk_x, message.chunk_z, message.seed);
|
||||
break;
|
||||
case "mesh": {
|
||||
const [opaque_vertices, opaque_count, transparent_vertices, transparent_count] = make_chunk_mesh(
|
||||
message.chunk_x,
|
||||
message.chunk_z,
|
||||
message.padded_chunk,
|
||||
blocks_registry,
|
||||
textures_info,
|
||||
image,
|
||||
);
|
||||
region.fill(message.chunks);
|
||||
region.compute(light_tables);
|
||||
const { solid, cutout, translucent } = make_chunk_mesh(message.chunk_x, message.chunk_z, message.camera);
|
||||
post(
|
||||
{
|
||||
type: "meshed",
|
||||
chunk_x: message.chunk_x,
|
||||
chunk_z: message.chunk_z,
|
||||
version: message.version,
|
||||
opaque_vertices,
|
||||
opaque_count,
|
||||
transparent_vertices,
|
||||
transparent_count,
|
||||
solid,
|
||||
cutout,
|
||||
translucent,
|
||||
camera: message.camera,
|
||||
},
|
||||
[opaque_vertices.buffer, transparent_vertices.buffer],
|
||||
[
|
||||
solid.vertices.buffer,
|
||||
cutout.vertices.buffer,
|
||||
translucent.vertices.buffer,
|
||||
translucent.indices.buffer,
|
||||
translucent.centers.buffer,
|
||||
...translucent.planes.map((planes) => planes.buffer),
|
||||
],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "sort": {
|
||||
const [x, y, z] = message.camera;
|
||||
const indices = quad_indices(sort_by_distance(message.centers, message.centers.length / 3, x, y, z));
|
||||
post({
|
||||
type: "sorted",
|
||||
chunk_x: message.chunk_x,
|
||||
chunk_z: message.chunk_z,
|
||||
version: message.version,
|
||||
sort_version: message.sort_version,
|
||||
indices,
|
||||
}, [indices.buffer]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,42 +163,167 @@ function generate(chunk_x: number, chunk_z: number, seed: string) {
|
||||
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
|
||||
}
|
||||
|
||||
function make_chunk_mesh(
|
||||
chunk_x: number,
|
||||
chunk_z: number,
|
||||
padded_chunk: Uint32Array,
|
||||
blocks_registry: BlockRegistry[],
|
||||
textures_info: TexturesInfo,
|
||||
image: Texture,
|
||||
): [Float32Array, number, Float32Array, number] {
|
||||
let opaque_vertices = new Float32Array(2048);
|
||||
let opaque_count = 0;
|
||||
let transparent_vertices = new Float32Array(2048);
|
||||
let transparent_count = 0;
|
||||
function build_block_tables() {
|
||||
blocks_registry.forEach((block, nid) => {
|
||||
if (!block || nid === AIR) return;
|
||||
const layer = LAYER_IDS[block.render_layer ?? "solid"];
|
||||
const opacity = block_light_opacity(block);
|
||||
block_layers[nid] = layer;
|
||||
block_cull_same[nid] = (block.cull_same ?? layer === TRANSLUCENT) ? 1 : 0;
|
||||
block_occludes[nid] = block.has_collision && layer !== TRANSLUCENT ? 1 : 0;
|
||||
block_lets_light_by[nid] = layer !== SOLID || opacity === 0 ? 1 : 0;
|
||||
light_tables.opacity[nid] = opacity;
|
||||
light_tables.emission[nid] = block_light_emission(block);
|
||||
});
|
||||
block_lets_light_by[AIR] = 1;
|
||||
block_layers[REGION_VOID] = SOLID;
|
||||
block_occludes[REGION_VOID] = 1;
|
||||
light_tables.opacity[REGION_VOID] = 15;
|
||||
}
|
||||
|
||||
const size = CHUNK_SIZE + 2;
|
||||
const layer = size * size;
|
||||
// the rule vanilla minecraft (and so sodium) uses: solid neighbors hide a face, and some blocks
|
||||
// hide faces between two of themselves
|
||||
function show_face(block: number, neighbor: number) {
|
||||
if (neighbor === AIR) return true;
|
||||
if (block_layers[neighbor] === SOLID) return false;
|
||||
return !(neighbor === block && block_cull_same[block]);
|
||||
}
|
||||
|
||||
const padded_index = (x: number, y: number, z: number) => {
|
||||
return y * layer + z * size + x;
|
||||
};
|
||||
// per corner of the face being built
|
||||
const corner_sky = new Float32Array(4);
|
||||
const corner_block = new Float32Array(4);
|
||||
const corner_ao = new Float32Array(4);
|
||||
|
||||
const show_face = (block: number) => {
|
||||
if (block === 0) return true;
|
||||
const info = blocks_registry[block];
|
||||
return info?.transparent ?? false;
|
||||
};
|
||||
// minecraft's smooth lighting: each corner averages the light of the cell in front of the face and the three
|
||||
// cells around it that touch the corner, and gets darker for each of those that's a full block
|
||||
function light_face_corners(face: number, front: number, front_y: number) {
|
||||
const front_id = region_block(region, front, front_y);
|
||||
const front_sky = region_sky(region, front, front_y);
|
||||
const front_block = region_block_light(region, front, front_y);
|
||||
const front_ao = block_occludes[front_id] ? 0.2 : 1;
|
||||
|
||||
for (let corner = 0; corner < 4; corner++) {
|
||||
const [a_offset, a_dy, b_offset, b_dy] = CORNER_SIDES[face][corner];
|
||||
const a = front + a_offset;
|
||||
const a_y = front_y + a_dy;
|
||||
const b = front + b_offset;
|
||||
const b_y = front_y + b_dy;
|
||||
const a_id = region_block(region, a, a_y);
|
||||
const b_id = region_block(region, b, b_y);
|
||||
|
||||
let a_sky = region_sky(region, a, a_y);
|
||||
let a_block = region_block_light(region, a, a_y);
|
||||
let b_sky = region_sky(region, b, b_y);
|
||||
let b_block = region_block_light(region, b, b_y);
|
||||
const a_ao = block_occludes[a_id] ? 0.2 : 1;
|
||||
const b_ao = block_occludes[b_id] ? 0.2 : 1;
|
||||
|
||||
// with both sides closed the corner cell can't be seen, vanilla uses a side's values instead
|
||||
let c_sky = a_sky;
|
||||
let c_block = a_block;
|
||||
let c_ao = a_ao;
|
||||
if (block_lets_light_by[a_id] || block_lets_light_by[b_id]) {
|
||||
const c = a + b_offset;
|
||||
const c_y = a_y + b_dy;
|
||||
c_sky = region_sky(region, c, c_y);
|
||||
c_block = region_block_light(region, c, c_y);
|
||||
c_ao = block_occludes[region_block(region, c, c_y)] ? 0.2 : 1;
|
||||
}
|
||||
|
||||
// cells with no light at all are usually inside solid blocks, vanilla counts them as the front cell
|
||||
// so corners against walls don't go black
|
||||
if (a_sky === 0 && a_block === 0) {
|
||||
a_sky = front_sky;
|
||||
a_block = front_block;
|
||||
}
|
||||
if (b_sky === 0 && b_block === 0) {
|
||||
b_sky = front_sky;
|
||||
b_block = front_block;
|
||||
}
|
||||
if (c_sky === 0 && c_block === 0) {
|
||||
c_sky = front_sky;
|
||||
c_block = front_block;
|
||||
}
|
||||
|
||||
corner_sky[corner] = (a_sky + b_sky + c_sky + front_sky) / 4;
|
||||
corner_block[corner] = (a_block + b_block + c_block + front_block) / 4;
|
||||
corner_ao[corner] = (a_ao + b_ao + c_ao + front_ao) / 4;
|
||||
}
|
||||
}
|
||||
|
||||
// sodium's rule for which diagonal splits the quad: the brighter one, otherwise the ambient occlusion
|
||||
// gets smeared across the whole face
|
||||
function should_flip() {
|
||||
const ao_02 = corner_ao[0] + corner_ao[2];
|
||||
const ao_13 = corner_ao[1] + corner_ao[3];
|
||||
if (ao_02 !== ao_13) {
|
||||
return ao_02 < ao_13;
|
||||
}
|
||||
const light = (corner: number) => corner_sky[corner] * 16 + corner_block[corner];
|
||||
return light(0) + light(2) > light(1) + light(3);
|
||||
}
|
||||
|
||||
function push_quad(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
face: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sprite: SpriteRegion,
|
||||
alpha: number,
|
||||
) {
|
||||
const u0 = (sprite.x * TEXTURE_SIZE + UV_PAD) / image.width;
|
||||
const v0 = (sprite.y * TEXTURE_SIZE + UV_PAD) / image.height;
|
||||
const u1 = ((sprite.x + 1) * TEXTURE_SIZE - UV_PAD) / image.width;
|
||||
const v1 = ((sprite.y + 1) * TEXTURE_SIZE - UV_PAD) / image.height;
|
||||
const shade = FACE_SHADE[face];
|
||||
// starting from the second corner moves the diagonal, the winding stays the same
|
||||
const first = should_flip() ? 1 : 0;
|
||||
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const corner = (first + k) & 3;
|
||||
const [cx, cy, cz] = FACE_CORNERS[face][corner];
|
||||
const [cu, cv] = CORNER_UVS[corner];
|
||||
const brightness = shade * corner_ao[corner];
|
||||
vertices[i++] = x + cx;
|
||||
vertices[i++] = y + cy;
|
||||
vertices[i++] = z + cz;
|
||||
vertices[i++] = cu ? u1 : u0;
|
||||
vertices[i++] = cv ? v1 : v0;
|
||||
vertices[i++] = brightness;
|
||||
vertices[i++] = brightness;
|
||||
vertices[i++] = brightness;
|
||||
vertices[i++] = alpha;
|
||||
// where to read the lightmap, block light across and sky light down
|
||||
vertices[i++] = (corner_block[corner] + 0.5) / 16;
|
||||
vertices[i++] = (corner_sky[corner] + 0.5) / 16;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
// region has to be filled and lit first
|
||||
function make_chunk_mesh(chunk_x: number, chunk_z: number, camera: number[]) {
|
||||
const layers = [SOLID, CUTOUT, TRANSLUCENT].map(() => ({ vertices: new Float32Array(4096), floats: 0 }));
|
||||
// for sorting the translucent quads
|
||||
let centers = new Float32Array(256);
|
||||
let faces = new Uint8Array(256);
|
||||
|
||||
// where the neighbor on each face is, same order as FACES
|
||||
const face_offsets = FACE_NORMALS.map(([nx, ny, nz]) => nx + ny * REGION_LAYER + nz * REGION_SIZE);
|
||||
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
for (let z = 0; z < CHUNK_SIZE; z++) {
|
||||
for (let x = 0; x < CHUNK_SIZE; x++) {
|
||||
const px = x + 1;
|
||||
const pz = z + 1;
|
||||
|
||||
const block_nid = padded_chunk[padded_index(px, y, pz)];
|
||||
if (block_nid === 0) continue;
|
||||
// the middle chunk of the region
|
||||
const index = y * REGION_LAYER + (z + CHUNK_SIZE) * REGION_SIZE + x + CHUNK_SIZE;
|
||||
const block_nid = region.blocks[index];
|
||||
if (block_nid === AIR) continue;
|
||||
|
||||
const block_info = blocks_registry[block_nid];
|
||||
const layer_id = block_layers[block_nid];
|
||||
const layer = layers[layer_id];
|
||||
const alpha = layer_id === TRANSLUCENT ? block_info.alpha ?? 1 : 1;
|
||||
|
||||
const texture_ids = {
|
||||
top: "engine:missing",
|
||||
@@ -402,72 +363,67 @@ function make_chunk_mesh(
|
||||
const wx = chunk_x * CHUNK_SIZE + x;
|
||||
const wz = chunk_z * CHUNK_SIZE + z;
|
||||
|
||||
const faces = {
|
||||
front: show_face(padded_chunk[padded_index(px, y, pz + 1)]),
|
||||
back: show_face(padded_chunk[padded_index(px, y, pz - 1)]),
|
||||
left: show_face(padded_chunk[padded_index(px - 1, y, pz)]),
|
||||
right: show_face(padded_chunk[padded_index(px + 1, y, pz)]),
|
||||
top: show_face(padded_chunk[padded_index(px, y + 1, pz)]),
|
||||
bottom: show_face(padded_chunk[padded_index(px, y - 1, pz)]),
|
||||
} as const;
|
||||
for (let face = 0; face < 6; face++) {
|
||||
const front = index + face_offsets[face];
|
||||
const front_y = y + FACE_NORMALS[face][1];
|
||||
if (!show_face(block_nid, region_block(region, front, front_y))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const side of ["front", "back", "left", "right", "top", "bottom"] as const) {
|
||||
if (faces[side]) {
|
||||
const region = textures_info[texture_ids[side]];
|
||||
light_face_corners(face, front, front_y);
|
||||
layer.vertices = ensure_capacity(layer.vertices, layer.floats + FLOATS_PER_QUAD);
|
||||
layer.floats = push_quad(
|
||||
layer.vertices,
|
||||
layer.floats,
|
||||
face,
|
||||
wx,
|
||||
y,
|
||||
wz,
|
||||
textures_info[texture_ids[FACES[face]]],
|
||||
alpha,
|
||||
);
|
||||
|
||||
if (block_info.transparent) {
|
||||
transparent_vertices = ensure_capacity(
|
||||
transparent_vertices,
|
||||
transparent_count + (6 * 6 * 9),
|
||||
);
|
||||
transparent_count = FACE_PUSHING_FUNCTIONS[side](
|
||||
transparent_vertices,
|
||||
transparent_count,
|
||||
image,
|
||||
wx,
|
||||
y,
|
||||
wz,
|
||||
region.x * TEXTURE_SIZE,
|
||||
region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
block_info.alpha ?? 1,
|
||||
);
|
||||
} else {
|
||||
opaque_vertices = ensure_capacity(opaque_vertices, opaque_count + (6 * 6 * 9));
|
||||
opaque_count = FACE_PUSHING_FUNCTIONS[side](
|
||||
opaque_vertices,
|
||||
opaque_count,
|
||||
image,
|
||||
wx,
|
||||
y,
|
||||
wz,
|
||||
region.x * TEXTURE_SIZE,
|
||||
region.y * TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
TEXTURE_SIZE,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (layer_id === TRANSLUCENT) {
|
||||
const quad = layer.floats / FLOATS_PER_QUAD - 1;
|
||||
centers = ensure_capacity(centers, (quad + 1) * 3);
|
||||
faces = ensure_capacity(faces, quad + 1);
|
||||
const [nx, ny, nz] = FACE_NORMALS[face];
|
||||
centers[quad * 3] = wx + 0.5 + nx * 0.5;
|
||||
centers[quad * 3 + 1] = y + 0.5 + ny * 0.5;
|
||||
centers[quad * 3 + 2] = wz + 0.5 + nz * 0.5;
|
||||
faces[quad] = face;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [opaque_vertices, opaque_count, transparent_vertices, transparent_count];
|
||||
const [solid, cutout, translucent] = layers.map((layer) => ({
|
||||
vertices: layer.vertices,
|
||||
quad_count: layer.floats / FLOATS_PER_QUAD,
|
||||
}));
|
||||
|
||||
const quads = { centers, faces, count: translucent.quad_count };
|
||||
const sort_type = choose_sort_type(quads);
|
||||
const [camera_x, camera_y, camera_z] = camera;
|
||||
|
||||
return {
|
||||
solid,
|
||||
cutout,
|
||||
translucent: {
|
||||
...translucent,
|
||||
indices: sort_quads(quads, sort_type, camera_x, camera_y, camera_z),
|
||||
sort_type,
|
||||
centers: centers.slice(0, quads.count * 3),
|
||||
planes: quad_planes(quads),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensure_capacity(
|
||||
buffer: Float32Array<ArrayBuffer>,
|
||||
function ensure_capacity<T extends Float32Array<ArrayBuffer> | Uint8Array<ArrayBuffer>>(
|
||||
buffer: T,
|
||||
required: number,
|
||||
) {
|
||||
): T {
|
||||
if (required <= buffer.length) return buffer;
|
||||
|
||||
let new_length = buffer.length;
|
||||
@@ -475,7 +431,7 @@ function ensure_capacity(
|
||||
new_length *= 2;
|
||||
}
|
||||
|
||||
const new_buffer = new Float32Array(new_length);
|
||||
new_buffer.set(buffer);
|
||||
const new_buffer = new (buffer.constructor as new (length: number) => T)(new_length);
|
||||
new_buffer.set(buffer as ArrayLike<number>);
|
||||
return new_buffer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// minecraft's lighting: every block has a sky light and a block light level from 0 to 15.
|
||||
// sky light starts at 15 above the world and goes straight down without getting weaker until it hits
|
||||
// something that isn't fully clear, block light starts at blocks that give off light. both spread to
|
||||
// neighbors losing max(1, the neighbor's opacity) per step.
|
||||
//
|
||||
// minecraft stores light and updates it as blocks change. here it's worked out from scratch for the
|
||||
// 3x3 chunks around the chunk being meshed, which gives the same result: light reaches at most 15
|
||||
// blocks, so nothing outside those chunks can light the middle one or its border
|
||||
|
||||
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
|
||||
|
||||
export const REGION_SIZE = CHUNK_SIZE * 3;
|
||||
export const REGION_LAYER = REGION_SIZE * REGION_SIZE;
|
||||
export const REGION_VOLUME = REGION_LAYER * CHUNK_HEIGHT;
|
||||
// what unloaded chunks and the space below the world are made of: opaque, dark, never shown
|
||||
export const REGION_VOID = ID_MASK;
|
||||
|
||||
// by numeric block id
|
||||
export interface LightTables {
|
||||
opacity: Uint8Array;
|
||||
emission: Uint8Array;
|
||||
}
|
||||
|
||||
export class LightRegion {
|
||||
// block ids without their state bits, indexed y * REGION_LAYER + z * REGION_SIZE + x
|
||||
blocks = new Uint16Array(REGION_VOLUME);
|
||||
sky = new Uint8Array(REGION_VOLUME);
|
||||
block_light = new Uint8Array(REGION_VOLUME);
|
||||
|
||||
// the lowest y that still sees the sky, per column
|
||||
#heights = new Int32Array(REGION_LAYER);
|
||||
#queue = new Int32Array(1 << 18);
|
||||
#queue_length = 0;
|
||||
|
||||
// the 3x3 chunks around the one being meshed, going +x then +z, starting at -x -z. missing ones are void
|
||||
fill(chunks: (Uint32Array | null)[]) {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const source = chunks[i];
|
||||
const origin = Math.floor(i / 3) * CHUNK_SIZE * REGION_SIZE + (i % 3) * CHUNK_SIZE;
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
for (let z = 0; z < CHUNK_SIZE; z++) {
|
||||
const to = y * REGION_LAYER + origin + z * REGION_SIZE;
|
||||
if (!source) {
|
||||
this.blocks.fill(REGION_VOID, to, to + CHUNK_SIZE);
|
||||
continue;
|
||||
}
|
||||
const from = y * CHUNK_AREA + z * CHUNK_SIZE;
|
||||
for (let x = 0; x < CHUNK_SIZE; x++) {
|
||||
this.blocks[to + x] = source[from + x] & ID_MASK;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compute(tables: LightTables) {
|
||||
this.#compute_sky(tables);
|
||||
this.#compute_block_light(tables);
|
||||
}
|
||||
|
||||
#compute_sky({ opacity }: LightTables) {
|
||||
const { blocks, sky } = this;
|
||||
sky.fill(0);
|
||||
this.#queue_length = 0;
|
||||
|
||||
// straight down from the top, until something isn't fully clear
|
||||
for (let column = 0; column < REGION_LAYER; column++) {
|
||||
let y = CHUNK_HEIGHT - 1;
|
||||
while (y >= 0 && opacity[blocks[y * REGION_LAYER + column]] === 0) {
|
||||
sky[y * REGION_LAYER + column] = 15;
|
||||
y--;
|
||||
}
|
||||
this.#heights[column] = y + 1;
|
||||
}
|
||||
|
||||
// only the lit cells next to a darker one can spread: the bottom of each column's sunlight,
|
||||
// and the part of it that's beside a neighbor column's shade
|
||||
for (let z = 0; z < REGION_SIZE; z++) {
|
||||
for (let x = 0; x < REGION_SIZE; x++) {
|
||||
const column = z * REGION_SIZE + x;
|
||||
const height = this.#heights[column];
|
||||
let highest_neighbor = height;
|
||||
if (x > 0) highest_neighbor = Math.max(highest_neighbor, this.#heights[column - 1]);
|
||||
if (x < REGION_SIZE - 1) highest_neighbor = Math.max(highest_neighbor, this.#heights[column + 1]);
|
||||
if (z > 0) highest_neighbor = Math.max(highest_neighbor, this.#heights[column - REGION_SIZE]);
|
||||
if (z < REGION_SIZE - 1) {
|
||||
highest_neighbor = Math.max(highest_neighbor, this.#heights[column + REGION_SIZE]);
|
||||
}
|
||||
const top = Math.min(CHUNK_HEIGHT - 1, Math.max(height, highest_neighbor - 1));
|
||||
for (let y = height; y <= top; y++) {
|
||||
this.#push(y * REGION_LAYER + column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#propagate(sky, opacity, true);
|
||||
}
|
||||
|
||||
#compute_block_light({ opacity, emission }: LightTables) {
|
||||
const { blocks, block_light } = this;
|
||||
block_light.fill(0);
|
||||
this.#queue_length = 0;
|
||||
|
||||
for (let i = 0; i < REGION_VOLUME; i++) {
|
||||
const level = emission[blocks[i]];
|
||||
if (level > 0) {
|
||||
block_light[i] = level;
|
||||
this.#push(i);
|
||||
}
|
||||
}
|
||||
|
||||
this.#propagate(block_light, opacity, false);
|
||||
}
|
||||
|
||||
#push(index: number) {
|
||||
if (this.#queue_length === this.#queue.length) {
|
||||
const bigger = new Int32Array(this.#queue.length * 2);
|
||||
bigger.set(this.#queue);
|
||||
this.#queue = bigger;
|
||||
}
|
||||
this.#queue[this.#queue_length++] = index;
|
||||
}
|
||||
|
||||
// breadth first from everything queued. a cell can be queued again when a brighter path reaches it
|
||||
#propagate(light: Uint8Array, opacity: Uint8Array, is_sky: boolean) {
|
||||
const blocks = this.blocks;
|
||||
|
||||
const spread = (to: number, level: number, down: boolean) => {
|
||||
const block_opacity = opacity[blocks[to]];
|
||||
const next = is_sky && down && level === 15 && block_opacity === 0
|
||||
? 15
|
||||
: level - Math.max(1, block_opacity);
|
||||
if (next > light[to]) {
|
||||
light[to] = next;
|
||||
this.#push(to);
|
||||
}
|
||||
};
|
||||
|
||||
for (let head = 0; head < this.#queue_length; head++) {
|
||||
const index = this.#queue[head];
|
||||
const level = light[index];
|
||||
if (level <= 1) continue;
|
||||
|
||||
const y = Math.floor(index / REGION_LAYER);
|
||||
const rest = index - y * REGION_LAYER;
|
||||
const z = Math.floor(rest / REGION_SIZE);
|
||||
const x = rest - z * REGION_SIZE;
|
||||
|
||||
if (y > 0) spread(index - REGION_LAYER, level, true);
|
||||
if (y < CHUNK_HEIGHT - 1) spread(index + REGION_LAYER, level, false);
|
||||
if (x > 0) spread(index - 1, level, false);
|
||||
if (x < REGION_SIZE - 1) spread(index + 1, level, false);
|
||||
if (z > 0) spread(index - REGION_SIZE, level, false);
|
||||
if (z < REGION_SIZE - 1) spread(index + REGION_SIZE, level, false);
|
||||
}
|
||||
this.#queue_length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// what a cell looks like to the mesher, including above and below the world
|
||||
export function region_block(region: LightRegion, index: number, y: number) {
|
||||
if (y >= CHUNK_HEIGHT) return AIR;
|
||||
if (y < 0) return REGION_VOID;
|
||||
return region.blocks[index];
|
||||
}
|
||||
|
||||
export function region_sky(region: LightRegion, index: number, y: number) {
|
||||
if (y >= CHUNK_HEIGHT) return 15;
|
||||
if (y < 0) return 0;
|
||||
return region.sky[index];
|
||||
}
|
||||
|
||||
export function region_block_light(region: LightRegion, index: number, y: number) {
|
||||
if (y >= CHUNK_HEIGHT || y < 0) return 0;
|
||||
return region.block_light[index];
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// translucent quad sorting, the way sodium does it (its "translucency sorting"), simplified for quads
|
||||
// that all face along an axis.
|
||||
// every chunk's translucent mesh gets a sort type when it's meshed:
|
||||
// - none: the order can't matter, one quad or all of them in a single plane
|
||||
// - static: all quads face along one axis, so sorting them by distance along their normal once is right
|
||||
// from anywhere the camera can see them
|
||||
// - dynamic: sorted by distance to the camera, and sorted again only when the camera crosses one of the
|
||||
// planes the chunk's quads lie on, the only time the order between them can change (sodium's GFNI)
|
||||
|
||||
export type SortType = "none" | "static" | "dynamic";
|
||||
|
||||
// same order as the mesher's faces
|
||||
export const FACE_NORMALS = [
|
||||
[0, 1, 0], // top
|
||||
[0, -1, 0], // bottom
|
||||
[0, 0, 1], // front
|
||||
[0, 0, -1], // back
|
||||
[-1, 0, 0], // left
|
||||
[1, 0, 0], // right
|
||||
] as const;
|
||||
|
||||
export const FACE_AXIS = [1, 1, 2, 2, 0, 0] as const;
|
||||
|
||||
// for each quad: its center, and which face it is
|
||||
export interface TranslucentQuads {
|
||||
centers: Float32Array;
|
||||
faces: Uint8Array;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function choose_sort_type(quads: TranslucentQuads): SortType {
|
||||
if (quads.count <= 1) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
let axes = 0;
|
||||
for (let q = 0; q < quads.count; q++) {
|
||||
axes |= 1 << FACE_AXIS[quads.faces[q]];
|
||||
}
|
||||
// more than one axis, the order depends on where the camera is
|
||||
if (axes & (axes - 1)) {
|
||||
return "dynamic";
|
||||
}
|
||||
|
||||
// quads facing opposite ways on the same axis are never both visible over each other, so only
|
||||
// the distance along the axis matters. if they all share a plane, nothing can overlap at all
|
||||
const axis = FACE_AXIS[quads.faces[0]];
|
||||
const plane = quads.centers[axis];
|
||||
for (let q = 1; q < quads.count; q++) {
|
||||
if (quads.centers[q * 3 + axis] !== plane) {
|
||||
return "static";
|
||||
}
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
// the unique coordinates of the planes the quads lie on, per axis, sorted. the camera crossing one of
|
||||
// these is what triggers a dynamic sort
|
||||
export function quad_planes(quads: TranslucentQuads): [Float32Array, Float32Array, Float32Array] {
|
||||
const sets = [new Set<number>(), new Set<number>(), new Set<number>()];
|
||||
for (let q = 0; q < quads.count; q++) {
|
||||
const axis = FACE_AXIS[quads.faces[q]];
|
||||
sets[axis].add(quads.centers[q * 3 + axis]);
|
||||
}
|
||||
return sets.map((set) => Float32Array.from(set).sort()) as [Float32Array, Float32Array, Float32Array];
|
||||
}
|
||||
|
||||
// whether moving the camera from a to b crosses any of the planes
|
||||
export function crosses_planes(planes: [Float32Array, Float32Array, Float32Array], a: number[], b: number[]) {
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
if (a[axis] === b[axis] || planes[axis].length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (count_below(planes[axis], a[axis]) !== count_below(planes[axis], b[axis])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function count_below(sorted: Float32Array, value: number) {
|
||||
let lo = 0;
|
||||
let hi = sorted.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (sorted[mid] < value) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
// back to front, for the camera at camera_x/y/z when the sort type is dynamic
|
||||
export function sort_quads(
|
||||
quads: TranslucentQuads,
|
||||
sort_type: SortType,
|
||||
camera_x: number,
|
||||
camera_y: number,
|
||||
camera_z: number,
|
||||
): Uint32Array {
|
||||
const { centers, faces, count } = quads;
|
||||
if (sort_type === "dynamic") {
|
||||
return quad_indices(sort_by_distance(centers, count, camera_x, camera_y, camera_z));
|
||||
}
|
||||
|
||||
const order = new Uint32Array(count);
|
||||
for (let q = 0; q < count; q++) {
|
||||
order[q] = q;
|
||||
}
|
||||
if (sort_type === "static") {
|
||||
// for quads facing the camera, the ones further along their normal are closer to it
|
||||
const keys = new Float32Array(count);
|
||||
for (let q = 0; q < count; q++) {
|
||||
const [nx, ny, nz] = FACE_NORMALS[faces[q]];
|
||||
keys[q] = centers[q * 3] * nx + centers[q * 3 + 1] * ny + centers[q * 3 + 2] * nz;
|
||||
}
|
||||
order.sort((a, b) => keys[a] - keys[b]);
|
||||
}
|
||||
return quad_indices(order);
|
||||
}
|
||||
|
||||
// dynamic sorting only needs the centers, so resorting doesn't need the whole mesh
|
||||
export function sort_by_distance(
|
||||
centers: Float32Array,
|
||||
count: number,
|
||||
camera_x: number,
|
||||
camera_y: number,
|
||||
camera_z: number,
|
||||
): Uint32Array {
|
||||
const order = new Uint32Array(count);
|
||||
const distances = new Float32Array(count);
|
||||
for (let q = 0; q < count; q++) {
|
||||
order[q] = q;
|
||||
const dx = centers[q * 3] - camera_x;
|
||||
const dy = centers[q * 3 + 1] - camera_y;
|
||||
const dz = centers[q * 3 + 2] - camera_z;
|
||||
distances[q] = dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
return order.sort((a, b) => distances[b] - distances[a]);
|
||||
}
|
||||
|
||||
// two triangles per quad, drawn in the given order
|
||||
export function quad_indices(order: Uint32Array): Uint32Array {
|
||||
const indices = new Uint32Array(order.length * 6);
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const v = order[i] * 4;
|
||||
const o = i * 6;
|
||||
indices[o] = v;
|
||||
indices[o + 1] = v + 1;
|
||||
indices[o + 2] = v + 2;
|
||||
indices[o + 3] = v;
|
||||
indices[o + 4] = v + 2;
|
||||
indices[o + 5] = v + 3;
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Component } from "$/common/ecs/component.ts";
|
||||
|
||||
export class Position extends Component {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
|
||||
constructor(x: number, y: number, z = 0) {
|
||||
super();
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Position(this.x, this.y, this.z);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Component } from "$/common/ecs/component.ts";
|
||||
|
||||
export class Velocity extends Component {
|
||||
vx: number;
|
||||
vy: number;
|
||||
vz: number;
|
||||
|
||||
constructor(vx: number, vy: number, vz = 0) {
|
||||
super();
|
||||
this.vx = vx;
|
||||
this.vy = vy;
|
||||
this.vz = vz;
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,16 @@ export const ID_MASK = 0xFFFF;
|
||||
export const STATE_SHIFT = 16;
|
||||
|
||||
export const CHUNK_SIZE = 16;
|
||||
export const CHUNK_HEIGHT = 128;
|
||||
export const CHUNK_HEIGHT = 256;
|
||||
// the top of oceans and rivers
|
||||
export const SEA_LEVEL = 64;
|
||||
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
||||
|
||||
// the player's collision box, position is the middle of its feet
|
||||
export const PLAYER_WIDTH = 0.55;
|
||||
export const PLAYER_HEIGHT = 1.79;
|
||||
export const PLAYER_EYE_HEIGHT = 1.69;
|
||||
|
||||
// where a block placed against each face of another block goes
|
||||
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
|
||||
top: { x: 0, y: 1, z: 0 },
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export abstract class Component {
|
||||
__component = true;
|
||||
}
|
||||
|
||||
export abstract class SerializableComponent extends Component {
|
||||
abstract serialize(): unknown;
|
||||
// right, this is static you cant do this,,
|
||||
// abstract deserialize<T>(data: unknown): T;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { Component } from "./component.ts";
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type ComponentConstructor<T extends Component> = new (...args: any[]) => T;
|
||||
|
||||
export class Entity {
|
||||
id: string;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
components = new Map<ComponentConstructor<any>, Component>();
|
||||
active = true;
|
||||
|
||||
constructor(id: string = crypto.randomUUID()) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
add<T extends Component>(component: T): T {
|
||||
this.components.set(component.constructor as ComponentConstructor<T>, component);
|
||||
return component;
|
||||
}
|
||||
|
||||
get<T extends Component>(type: ComponentConstructor<T>): T | undefined {
|
||||
return this.components.get(type) as T;
|
||||
}
|
||||
|
||||
get_all(): Iterable<Component> {
|
||||
return this.components.values();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./component.ts";
|
||||
export * from "./entity.ts";
|
||||
export * from "./system.ts";
|
||||
export * from "./world.ts";
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
export abstract class System {
|
||||
abstract update(world: World, delta: number): void;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { Entity } from "./entity.ts";
|
||||
import type { System } from "./system.ts";
|
||||
|
||||
export class World {
|
||||
#entities = new Set<Entity>();
|
||||
#entities_for_deletion = new Set<Entity>();
|
||||
|
||||
#systems = new Map<string, Set<System>>();
|
||||
#tags = new Map<string, Entity[]>();
|
||||
#states = new Set<string>();
|
||||
|
||||
#state: string = "";
|
||||
#new_state: string | undefined;
|
||||
|
||||
constructor(initial_state: string) {
|
||||
this.#state = initial_state;
|
||||
this.add_state("*");
|
||||
}
|
||||
|
||||
add_state(new_state: string) {
|
||||
this.#states.add(new_state);
|
||||
this.#systems.set(new_state, new Set());
|
||||
}
|
||||
|
||||
add_entity(entity: Entity) {
|
||||
this.#entities.add(entity);
|
||||
}
|
||||
|
||||
add_system(system: System, state: string) {
|
||||
console.assert(this.#states.has(state));
|
||||
this.#systems.get(state)!.add(system);
|
||||
}
|
||||
|
||||
add_tag(tag: string, entities: Entity[]) {
|
||||
this.#tags.set(tag, entities);
|
||||
}
|
||||
|
||||
get_tag(tag: string) {
|
||||
return this.#tags.get(tag);
|
||||
}
|
||||
|
||||
update(delta: number) {
|
||||
for (const system of this.#systems.get("*") ?? []) {
|
||||
system.update(this, delta);
|
||||
}
|
||||
for (const system of this.#systems.get(this.#state) ?? []) {
|
||||
system.update(this, delta);
|
||||
}
|
||||
// should be faster than Set.prototype.difference lol
|
||||
for (const entity of this.#entities_for_deletion) {
|
||||
this.#entities.delete(entity);
|
||||
}
|
||||
if (this.#new_state) {
|
||||
this.#state = this.#new_state;
|
||||
this.#new_state = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get_entities() {
|
||||
return this.#entities;
|
||||
}
|
||||
|
||||
delete_entity(entity: Entity) {
|
||||
this.#entities_for_deletion.add(entity);
|
||||
}
|
||||
|
||||
clear_entities() {
|
||||
for (const entity of this.#entities) {
|
||||
this.#entities_for_deletion.add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
set state(new_state: string) {
|
||||
this.#new_state = new_state;
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,32 @@ interface BlockStateVariant {
|
||||
y: number;
|
||||
}
|
||||
|
||||
// solid: fully opaque. cutout: texels are either opaque or see-through (leaves).
|
||||
// translucent: blended and sorted back to front (water, glass)
|
||||
export const RENDER_LAYERS = ["solid", "cutout", "translucent"] as const;
|
||||
export type RenderLayer = typeof RENDER_LAYERS[number];
|
||||
|
||||
// like minecraft: solid blocks stop light, everything else lets it through unless it says otherwise
|
||||
export function block_light_opacity(block: BlockRegistry | undefined): number {
|
||||
if (!block) return 0;
|
||||
return block.light_opacity ?? ((block.render_layer ?? "solid") === "solid" ? 15 : 0);
|
||||
}
|
||||
|
||||
export function block_light_emission(block: BlockRegistry | undefined): number {
|
||||
return block?.light_emission ?? 0;
|
||||
}
|
||||
|
||||
export interface BlockRegistry {
|
||||
id: string;
|
||||
textures: string | TextureSideTopBottom | TextureFront;
|
||||
transparent?: boolean;
|
||||
// solid when not set. anything else doesn't hide its neighbors' faces
|
||||
render_layer?: RenderLayer;
|
||||
// hide faces between two of this block, like glass and water. defaults to true for translucent blocks
|
||||
cull_same?: boolean;
|
||||
// light level 0-15 it gives off
|
||||
light_emission?: number;
|
||||
// how much light going through it loses, 0-15. see block_light_opacity for the default
|
||||
light_opacity?: number;
|
||||
alpha?: number;
|
||||
|
||||
has_collision: boolean;
|
||||
|
||||
@@ -1,304 +1,23 @@
|
||||
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
|
||||
// generating a chunk: the overworld's terrain (common/worldgen), then ores, then mods' features.
|
||||
// runs in chunk workers on the server and every client, which must all get the same blocks
|
||||
import { Alea } from "@paulaboks/rng";
|
||||
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts";
|
||||
import type { FeatureChunk } from "$/common/mod_api/worldgen.ts";
|
||||
import type { OreJson } from "$/common/mod_data.ts";
|
||||
import { generate_overworld } from "./worldgen/overworld.ts";
|
||||
import { named_noise_2d, named_noise_3d } from "./worldgen/noise.ts";
|
||||
|
||||
// generation runs in a worker now, so it only needs somewhere to put blocks
|
||||
export interface BlockSink {
|
||||
add_block(block: { x: number; y: number; z: number; id: string }): void;
|
||||
// the surface height and biome of each column, for later passes
|
||||
set_column?(x: number, z: number, height: number, biome: string): void;
|
||||
}
|
||||
export { named_noise_2d, named_noise_3d };
|
||||
|
||||
type Biome =
|
||||
| "desert"
|
||||
| "plains"
|
||||
| "forest"
|
||||
| "jungle"
|
||||
| "tundra"
|
||||
| "taiga"
|
||||
| "snow"
|
||||
| "savanna"
|
||||
| "swamp";
|
||||
|
||||
type OreDef = {
|
||||
id: string;
|
||||
min_y: number;
|
||||
max_y: number;
|
||||
scale: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
const ORES: OreDef[] = [
|
||||
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
|
||||
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
|
||||
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
|
||||
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
|
||||
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
|
||||
// the base game's ores, placed like mods' ores.json. the world is 256 tall with the sea at 64
|
||||
const BASE_ORES: OreJson[] = [
|
||||
{ id: "bworld:coal_ore", replaces: "bworld:stone", min_y: 5, max_y: 200, scale: 0.05, threshold: 0.55 },
|
||||
{ id: "bworld:copper_ore", replaces: "bworld:stone", min_y: 5, max_y: 110, scale: 0.06, threshold: 0.6 },
|
||||
{ id: "bworld:tin_ore", replaces: "bworld:stone", min_y: 5, max_y: 70, scale: 0.06, threshold: 0.62 },
|
||||
{ id: "bworld:iron_ore", replaces: "bworld:stone", min_y: 5, max_y: 80, scale: 0.05, threshold: 0.65 },
|
||||
{ id: "bworld:gold_ore", replaces: "bworld:stone", min_y: 5, max_y: 36, scale: 0.04, threshold: 0.7 },
|
||||
];
|
||||
|
||||
function get_biome(temp: number, moisture: number): Biome {
|
||||
if (temp > 0.6) {
|
||||
if (moisture < -0.2) {
|
||||
return "desert";
|
||||
}
|
||||
if (moisture > 0.4) {
|
||||
return "jungle";
|
||||
}
|
||||
return "savanna";
|
||||
}
|
||||
if (temp > 0) {
|
||||
if (moisture > 0.5) {
|
||||
return "swamp";
|
||||
}
|
||||
if (moisture > 0) {
|
||||
return "forest";
|
||||
}
|
||||
return "plains";
|
||||
}
|
||||
if (temp > -0.5) {
|
||||
return "taiga";
|
||||
}
|
||||
return "tundra";
|
||||
}
|
||||
|
||||
function get_surface_block(biome: Biome) {
|
||||
if (biome === "desert") {
|
||||
return "bworld:sand";
|
||||
} else if (biome === "tundra") {
|
||||
return "bworld:snow";
|
||||
}
|
||||
return "bworld:grass";
|
||||
}
|
||||
|
||||
function biome_height_modifier(biome: Biome) {
|
||||
if (biome === "desert") {
|
||||
return 0.2;
|
||||
}
|
||||
if (biome === "plains") {
|
||||
return 0.4;
|
||||
}
|
||||
if (biome === "forest") {
|
||||
return 0.5;
|
||||
}
|
||||
if (biome === "jungle") {
|
||||
return 0.45;
|
||||
}
|
||||
if (biome === "taiga") {
|
||||
return 0.55;
|
||||
}
|
||||
if (biome === "tundra") {
|
||||
return 0.35;
|
||||
}
|
||||
if (biome === "savanna") {
|
||||
return 0.4;
|
||||
}
|
||||
if (biome === "swamp") {
|
||||
return 0.35;
|
||||
}
|
||||
|
||||
return 0.4;
|
||||
}
|
||||
|
||||
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
|
||||
let value = 0;
|
||||
let amp = 1;
|
||||
let freq = 1;
|
||||
let max = 0;
|
||||
for (let i = 0; i < octaves; i++) {
|
||||
value += noise(x * freq, y * freq) * amp;
|
||||
max += amp;
|
||||
amp *= 0.5;
|
||||
freq *= 2;
|
||||
}
|
||||
return value / max;
|
||||
}
|
||||
|
||||
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
|
||||
const biomeMod = biome_height_modifier(biome);
|
||||
|
||||
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
|
||||
|
||||
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
|
||||
|
||||
return Math.floor(base + biomeMod * 20 + main + detail);
|
||||
}
|
||||
|
||||
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
|
||||
const TREE_SPACING = 4;
|
||||
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
|
||||
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
|
||||
const nx = local_x + dx;
|
||||
const nz = local_z + dz;
|
||||
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function place_tree(dimension: BlockSink, rng: Alea, x: number, y: number, z: number, biome: Biome) {
|
||||
const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4);
|
||||
const trunk_block = "bworld:log";
|
||||
const leaves_block = "bworld:leaves";
|
||||
|
||||
for (let i = 0; i < height; i++) {
|
||||
dimension.add_block({ x, y: y + i, z, id: trunk_block });
|
||||
}
|
||||
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
for (let dz = -2; dz <= 2; dz++) {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
|
||||
dimension.add_block({
|
||||
x: x + dx,
|
||||
y: y + height + dy,
|
||||
z: z + dz,
|
||||
id: leaves_block,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TREE_THRESHOLD: Record<Biome, number> = {
|
||||
forest: 0.5,
|
||||
jungle: 0.3,
|
||||
taiga: 0.6,
|
||||
plains: 0.95,
|
||||
desert: 1,
|
||||
tundra: 1,
|
||||
savanna: 0.65,
|
||||
swamp: 0.5,
|
||||
snow: 0.8,
|
||||
};
|
||||
|
||||
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
|
||||
const n = feature_noise(x * 0.1, z * 0.1);
|
||||
return n > (TREE_THRESHOLD[biome] ?? 0.8);
|
||||
}
|
||||
|
||||
interface SeedNoises {
|
||||
height_noise: NoiseFunction2D;
|
||||
temp_noise: NoiseFunction2D;
|
||||
moisture_noise: NoiseFunction2D;
|
||||
feature_noise: NoiseFunction2D;
|
||||
ore_noises: NoiseFunction3D[];
|
||||
}
|
||||
|
||||
// building the permutation tables is expensive, only do it once per seed
|
||||
const noise_cache = new Map<string, SeedNoises>();
|
||||
|
||||
function get_noises(seed: string): SeedNoises {
|
||||
let noises = noise_cache.get(seed);
|
||||
if (!noises) {
|
||||
noises = {
|
||||
height_noise: create_noise_2d(new Alea(seed + "_height")),
|
||||
temp_noise: create_noise_2d(new Alea(seed + "_temp")),
|
||||
moisture_noise: create_noise_2d(new Alea(seed + "_moisture")),
|
||||
feature_noise: create_noise_2d(new Alea(seed + "_feature")),
|
||||
ore_noises: ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))),
|
||||
};
|
||||
noise_cache.set(seed, noises);
|
||||
}
|
||||
return noises;
|
||||
}
|
||||
|
||||
export function generate_chunk(dimension: BlockSink, cx: number, cz: number, seed = "seed") {
|
||||
const { height_noise, temp_noise, moisture_noise, feature_noise, ore_noises } = get_noises(seed);
|
||||
// seeded per chunk so every client generates the exact same terrain
|
||||
const rng = new Alea(`${seed}_chunk_${cx}_${cz}`);
|
||||
|
||||
const biome_scale = 0.003;
|
||||
const terrain_scale = 0.01;
|
||||
|
||||
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
|
||||
|
||||
for (let x = 0; x < CHUNK_SIZE; x++) {
|
||||
for (let z = 0; z < CHUNK_SIZE; z++) {
|
||||
const wx = cx * CHUNK_SIZE + x;
|
||||
const wz = cz * CHUNK_SIZE + z;
|
||||
|
||||
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
|
||||
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
|
||||
const biome = get_biome(temp, moisture);
|
||||
|
||||
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
|
||||
const base_height = (height_noise_value + 1) * 15 + 50;
|
||||
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
|
||||
|
||||
const surface_block = get_surface_block(biome);
|
||||
dimension.set_column?.(wx, wz, height, `bworld:${biome}`);
|
||||
|
||||
for (let y = 0; y <= height; y++) {
|
||||
let block = "bworld:stone";
|
||||
|
||||
if (y < height - 3) {
|
||||
for (let i = 0; i < ORES.length; i++) {
|
||||
const ore = ORES[i];
|
||||
|
||||
if (y >= ore.min_y && y <= ore.max_y) {
|
||||
const noise = ore_noises[i](
|
||||
wx * ore.scale,
|
||||
y * ore.scale,
|
||||
wz * ore.scale,
|
||||
);
|
||||
|
||||
if (noise > ore.threshold) {
|
||||
block = ore.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (y === height) {
|
||||
block = surface_block;
|
||||
} else if (y > height - 4) {
|
||||
block = "bworld:dirt";
|
||||
}
|
||||
|
||||
if (biome === "swamp" && y === height && rng.next() < 0.2) {
|
||||
block = "bworld:water";
|
||||
}
|
||||
|
||||
dimension.add_block({ x: wx, y, z: wz, id: block });
|
||||
}
|
||||
|
||||
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
|
||||
place_tree(dimension, rng, wx, height + 1, wz, biome);
|
||||
tree_map[x][z] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's
|
||||
const mod_noise_2d = new Map<string, NoiseFunction2D>();
|
||||
const mod_noise_3d = new Map<string, NoiseFunction3D>();
|
||||
|
||||
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
|
||||
const key = `${seed}_${name}`;
|
||||
let noise = mod_noise_2d.get(key);
|
||||
if (!noise) {
|
||||
noise = create_noise_2d(new Alea(key));
|
||||
mod_noise_2d.set(key, noise);
|
||||
}
|
||||
return noise;
|
||||
}
|
||||
|
||||
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
|
||||
const key = `${seed}_${name}`;
|
||||
let noise = mod_noise_3d.get(key);
|
||||
if (!noise) {
|
||||
noise = create_noise_3d(new Alea(key));
|
||||
mod_noise_3d.set(key, noise);
|
||||
}
|
||||
return noise;
|
||||
}
|
||||
|
||||
// what mods add to generation, see "World generation" in MODS.md
|
||||
export interface WorldgenSetup {
|
||||
ores: OreJson[];
|
||||
@@ -308,7 +27,7 @@ export interface WorldgenSetup {
|
||||
export interface RawChunk {
|
||||
// numeric block ids, only what this chunk generated itself
|
||||
blocks: Uint32Array;
|
||||
// blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id
|
||||
// blocks it generated in other chunks (from features like a mod's trees), flattened as x, y, z, numeric id
|
||||
spills: Int32Array;
|
||||
}
|
||||
|
||||
@@ -347,24 +66,21 @@ export function generate_raw_chunk(
|
||||
blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid;
|
||||
};
|
||||
|
||||
generate_chunk(
|
||||
{
|
||||
add_block(block) {
|
||||
const nid = block_ids[block.id];
|
||||
if (nid !== undefined) {
|
||||
set(block.x, block.y, block.z, nid);
|
||||
}
|
||||
},
|
||||
set_column(x, z, height, biome) {
|
||||
const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE);
|
||||
heights[index] = height;
|
||||
biomes[index] = biome;
|
||||
},
|
||||
},
|
||||
chunk_x,
|
||||
chunk_z,
|
||||
seed,
|
||||
);
|
||||
const id = (name: string) => {
|
||||
const nid = block_ids[name];
|
||||
return nid === undefined ? AIR : default_values?.[nid] ?? nid;
|
||||
};
|
||||
generate_overworld(blocks, heights, biomes, chunk_x, chunk_z, seed, {
|
||||
stone: id("bworld:stone"),
|
||||
dirt: id("bworld:dirt"),
|
||||
grass: id("bworld:grass"),
|
||||
sand: id("bworld:sand"),
|
||||
snow: id("bworld:snow"),
|
||||
water: id("bworld:water"),
|
||||
log: id("bworld:log"),
|
||||
leaves: id("bworld:leaves"),
|
||||
}, (x, y, z, block) => spills.push(x, y, z, block));
|
||||
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, BASE_ORES, default_values);
|
||||
|
||||
if (worldgen) {
|
||||
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values);
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface Player {
|
||||
readonly inventory: Container; // 36 slots, hotbar is 0-8
|
||||
readonly selected_slot: number;
|
||||
readonly held_item: ItemStack | undefined;
|
||||
// what doesn't fit in the inventory drops at their feet
|
||||
give_item(id: Id, count?: number, data?: unknown): void;
|
||||
send_message(text: string): void;
|
||||
teleport(x: number, y: number, z: number): void;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// the json formats from MODS.md, and converting them to and from the engine's registry entries.
|
||||
// the mod loader uses the from_json direction, tests use both to check nothing is lost
|
||||
import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts";
|
||||
import {
|
||||
type BlockRegistry,
|
||||
type BlockStateDefinition,
|
||||
type ItemRegistry,
|
||||
RENDER_LAYERS,
|
||||
type RenderLayer,
|
||||
} from "./everything_registry.ts";
|
||||
|
||||
export const FORMAT_VERSION = 1;
|
||||
export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
|
||||
@@ -25,6 +31,11 @@ export interface ManifestJson {
|
||||
export interface BlockJson {
|
||||
id: string;
|
||||
textures: BlockTextures;
|
||||
render_layer?: RenderLayer;
|
||||
cull_same?: boolean;
|
||||
light_emission?: number;
|
||||
light_opacity?: number;
|
||||
// replaced by render_layer, true means translucent
|
||||
transparent?: boolean;
|
||||
alpha?: number;
|
||||
collision?: boolean;
|
||||
@@ -78,7 +89,10 @@ export interface GridRecipe {
|
||||
|
||||
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
|
||||
const json: BlockJson = { id: block.id, textures: block.textures };
|
||||
if (block.transparent) json.transparent = true;
|
||||
if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer;
|
||||
if (block.cull_same !== undefined) json.cull_same = block.cull_same;
|
||||
if (block.light_emission !== undefined) json.light_emission = block.light_emission;
|
||||
if (block.light_opacity !== undefined) json.light_opacity = block.light_opacity;
|
||||
if (block.alpha !== undefined) json.alpha = block.alpha;
|
||||
if (!block.has_collision) json.collision = false;
|
||||
if (block.toughness !== undefined) {
|
||||
@@ -101,7 +115,11 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it
|
||||
textures: json.textures,
|
||||
has_collision: json.collision ?? true,
|
||||
};
|
||||
if (json.transparent) block.transparent = true;
|
||||
const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined);
|
||||
if (render_layer && render_layer !== "solid") block.render_layer = render_layer;
|
||||
if (json.cull_same !== undefined) block.cull_same = json.cull_same;
|
||||
if (json.light_emission !== undefined) block.light_emission = json.light_emission;
|
||||
if (json.light_opacity !== undefined) block.light_opacity = json.light_opacity;
|
||||
if (json.alpha !== undefined) block.alpha = json.alpha;
|
||||
if (json.mining) {
|
||||
block.toughness = json.mining.toughness;
|
||||
@@ -243,7 +261,16 @@ export function validate_block(json: unknown): Problems {
|
||||
(["top", "bottom", "side"].every((k) => typeof textures[k] === "string") ||
|
||||
["front", "side"].every((k) => typeof textures[k] === "string")));
|
||||
if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }");
|
||||
for (const key of ["transparent", "collision", "item", "interactive", "replaceable"]) {
|
||||
if (json.render_layer !== undefined && !RENDER_LAYERS.includes(json.render_layer as RenderLayer)) {
|
||||
problems.push(`render_layer must be one of ${RENDER_LAYERS.join(", ")}`);
|
||||
}
|
||||
for (const key of ["light_emission", "light_opacity"]) {
|
||||
const value = json[key];
|
||||
if (value !== undefined && (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 15)) {
|
||||
problems.push(`${key} must be a whole number from 0 to 15`);
|
||||
}
|
||||
}
|
||||
for (const key of ["cull_same", "transparent", "collision", "item", "interactive", "replaceable"]) {
|
||||
if (json[key] !== undefined && typeof json[key] !== "boolean") problems.push(`${key} must be true or false`);
|
||||
}
|
||||
if (json.alpha !== undefined && (typeof json.alpha !== "number" || json.alpha < 0 || json.alpha > 1)) {
|
||||
|
||||
@@ -30,8 +30,10 @@ export interface ModListing {
|
||||
data: string;
|
||||
client?: string;
|
||||
worldgen?: string;
|
||||
// the manifest's credits file, markdown, shown on the credits screen
|
||||
credits?: string;
|
||||
// sha-256 in hex of each file, clients check these before using them
|
||||
sha256: { data: string; client?: string; worldgen?: string };
|
||||
sha256: { data: string; client?: string; worldgen?: string; credits?: string };
|
||||
}
|
||||
|
||||
// the texture atlas with every mod's textures, built by the server
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// collision against blocks, shared by the client's entities and the server's, so both move things the same way
|
||||
import { TICK_DELTA } from "./constants.ts";
|
||||
|
||||
// how far inside a block face still counts as touching it, so boxes resting exactly on a face don't snag
|
||||
const EPSILON = 1e-7;
|
||||
// the two axes that aren't the one being moved along
|
||||
const OTHER_AXES = [[1, 2], [0, 2], [0, 1]] as const;
|
||||
|
||||
// something with a box that moves. position is the middle of its feet, velocity is in blocks per second
|
||||
export interface Body {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
vz: number;
|
||||
// width is used for both x and z
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// which way the body hit something on each axis: 1 or -1, 0 for nothing. 1 on y means it landed on something
|
||||
export interface Collisions {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
// falls and moves a body by its velocity for one tick. like minecraft it moves along y, then x, then z, each
|
||||
// time only as far as it can before touching a block, and stops its velocity on the axes it hit something.
|
||||
// blocks it's already inside don't stop it, so it can get out of them
|
||||
export function move_body(body: Body, gravity: number, is_solid: (x: number, y: number, z: number) => boolean) {
|
||||
// the average of this tick's start and end speed, so the arc is the same at any tick rate
|
||||
const wanted_y = (body.vy + gravity * TICK_DELTA / 2) * TICK_DELTA;
|
||||
body.vy += gravity * TICK_DELTA;
|
||||
const wanted = [body.vx * TICK_DELTA, wanted_y, body.vz * TICK_DELTA];
|
||||
|
||||
const half = body.width / 2;
|
||||
const min = [body.x - half, body.y, body.z - half];
|
||||
const max = [body.x + half, body.y + body.height, body.z + half];
|
||||
const moved = [0, 0, 0];
|
||||
for (const axis of [1, 0, 2]) {
|
||||
const distance = clip(min, max, axis, wanted[axis], is_solid);
|
||||
min[axis] += distance;
|
||||
max[axis] += distance;
|
||||
moved[axis] = distance;
|
||||
}
|
||||
|
||||
const hit = (axis: number) => moved[axis] !== wanted[axis] ? -Math.sign(wanted[axis]) : 0;
|
||||
const collisions: Collisions = { x: hit(0), y: hit(1), z: hit(2) };
|
||||
if (collisions.x !== 0) body.vx = 0;
|
||||
if (collisions.y !== 0) body.vy = 0;
|
||||
if (collisions.z !== 0) body.vz = 0;
|
||||
|
||||
body.x += moved[0];
|
||||
body.y += moved[1];
|
||||
body.z += moved[2];
|
||||
return collisions;
|
||||
}
|
||||
|
||||
// how far the box can go along an axis before it runs into a block
|
||||
function clip(
|
||||
min: number[],
|
||||
max: number[],
|
||||
axis: number,
|
||||
distance: number,
|
||||
is_solid: (x: number, y: number, z: number) => boolean,
|
||||
) {
|
||||
if (distance === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [a, b] = OTHER_AXES[axis];
|
||||
const from = Math.floor(Math.min(min[axis], min[axis] + distance));
|
||||
const to = Math.floor(Math.max(max[axis], max[axis] + distance));
|
||||
const position = [0, 0, 0];
|
||||
|
||||
for (let i = from; i <= to; i++) {
|
||||
for (let j = Math.floor(min[a] + EPSILON); j <= Math.floor(max[a] - EPSILON); j++) {
|
||||
for (let k = Math.floor(min[b] + EPSILON); k <= Math.floor(max[b] - EPSILON); k++) {
|
||||
position[axis] = i;
|
||||
position[a] = j;
|
||||
position[b] = k;
|
||||
if (!is_solid(position[0], position[1], position[2])) {
|
||||
continue;
|
||||
}
|
||||
if (distance > 0 && i >= max[axis] - EPSILON) {
|
||||
distance = Math.min(distance, i - max[axis]);
|
||||
} else if (distance < 0 && i + 1 <= min[axis] + EPSILON) {
|
||||
distance = Math.max(distance, i + 1 - min[axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export const AIR_ID = "bworld:air";
|
||||
|
||||
// bump when a client and server of different versions can't play together.
|
||||
// the server rejects a different version before the client downloads anything
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
export const PROTOCOL_VERSION = 2;
|
||||
|
||||
export interface PlayerInfo {
|
||||
id: string;
|
||||
@@ -19,6 +19,16 @@ export interface PlayerInfo {
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
// an entity that isn't a player, as the server first sends it. players have their own messages
|
||||
export interface EntityInfo {
|
||||
kind: "item";
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
item: ItemData;
|
||||
}
|
||||
|
||||
// x, y, z, block id, and its state bits when they aren't 0
|
||||
export type BlockChange = [number, number, number, string, number?];
|
||||
|
||||
@@ -61,6 +71,8 @@ export type ClientMessage =
|
||||
| { type: "use_block"; x: number; y: number; z: number; face: Faces }
|
||||
| { type: "select_slot"; slot: number }
|
||||
| { type: "click"; container: ContainerKey; index: number; button: number }
|
||||
// q on a slot (the held one when no screen is open) throws one item, or the whole stack with ctrl
|
||||
| { type: "drop_item"; container: ContainerKey; index: number; all: boolean }
|
||||
// closes the open screen, including the player's own inventory screen
|
||||
| { type: "close_screen" }
|
||||
| { type: "chat"; text: string };
|
||||
@@ -84,6 +96,8 @@ export type ServerMessage =
|
||||
// the server may change the name asked for, like alice to alice2
|
||||
name: string;
|
||||
players: PlayerInfo[];
|
||||
// items on the ground
|
||||
entities: EntityInfo[];
|
||||
changes: BlockChange[];
|
||||
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||
selected_slot: number;
|
||||
@@ -91,6 +105,13 @@ export type ServerMessage =
|
||||
| { type: "player_join"; player: PlayerInfo }
|
||||
| { type: "player_leave"; id: string }
|
||||
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
|
||||
| { type: "add_entity"; entity: EntityInfo }
|
||||
| { type: "move_entity"; id: string; x: number; y: number; z: number }
|
||||
// a dropped item's stack changed, like when two stacks merge
|
||||
| { type: "set_entity_item"; id: string; item: ItemData }
|
||||
| { type: "remove_entity"; id: string }
|
||||
// a player picked up an item, it flies to them and goes away
|
||||
| { type: "take_entity"; id: string; player: string }
|
||||
// also sent to the player who caused it, which corrects anything their client predicted wrong
|
||||
| { type: "set_block"; x: number; y: number; z: number; id: string; state?: number }
|
||||
| { type: "chat"; from?: string; text: string }
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { System, World } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
|
||||
export class MovementSystem extends System {
|
||||
update(world: World, delta: number): void {
|
||||
for (const entity of world.get_entities()) {
|
||||
const position = entity.get(Position);
|
||||
const velocity = entity.get(Velocity);
|
||||
|
||||
if (position && velocity) {
|
||||
position.x += velocity.vx * delta;
|
||||
position.y += velocity.vy * delta;
|
||||
position.z += velocity.vz * delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// noise for world generation. everything is built from named noises, the same ones mods get through
|
||||
// FeatureChunk.noise_2d and noise_3d, so a generator using them gives the same world wherever it runs
|
||||
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
|
||||
|
||||
// create_noise_2d(new Alea(seed + "_" + name)), cached since building the permutation tables is slow
|
||||
const noise_2d_cache = new Map<string, NoiseFunction2D>();
|
||||
const noise_3d_cache = new Map<string, NoiseFunction3D>();
|
||||
|
||||
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
|
||||
const key = `${seed}_${name}`;
|
||||
let noise = noise_2d_cache.get(key);
|
||||
if (!noise) {
|
||||
noise = create_noise_2d(new Alea(key));
|
||||
noise_2d_cache.set(key, noise);
|
||||
}
|
||||
return noise;
|
||||
}
|
||||
|
||||
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
|
||||
const key = `${seed}_${name}`;
|
||||
let noise = noise_3d_cache.get(key);
|
||||
if (!noise) {
|
||||
noise = create_noise_3d(new Alea(key));
|
||||
noise_3d_cache.set(key, noise);
|
||||
}
|
||||
return noise;
|
||||
}
|
||||
|
||||
// several octaves of simplex noise, like minecraft's NormalNoise: each octave has twice the frequency and half the
|
||||
// strength of the last, times its amplitude. wavelength is the size in blocks of the first octave's features, an
|
||||
// amplitude of 0 skips that octave. the result is roughly -1 to 1 but bunched in the middle, see Quantiles for an
|
||||
// even spread
|
||||
export class OctaveNoise2D {
|
||||
#octaves: { noise: NoiseFunction2D; frequency: number; amplitude: number }[] = [];
|
||||
#total: number;
|
||||
|
||||
constructor(seed: string, name: string, wavelength: number, amplitudes: number[]) {
|
||||
amplitudes.forEach((amplitude, i) => {
|
||||
if (amplitude !== 0) {
|
||||
this.#octaves.push({
|
||||
noise: named_noise_2d(seed, `${name}_${i}`),
|
||||
frequency: 2 ** i / wavelength,
|
||||
amplitude: amplitude / 2 ** i,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.#total = amplitudes.reduce((sum, amplitude, i) => sum + amplitude / 2 ** i, 0);
|
||||
}
|
||||
|
||||
sample(x: number, z: number) {
|
||||
let value = 0;
|
||||
for (const { noise, frequency, amplitude } of this.#octaves) {
|
||||
value += noise(x * frequency, z * frequency) * amplitude;
|
||||
}
|
||||
return value / this.#total;
|
||||
}
|
||||
}
|
||||
|
||||
export class OctaveNoise3D {
|
||||
#octaves: { noise: NoiseFunction3D; frequency: number; amplitude: number }[] = [];
|
||||
#total: number;
|
||||
// how much slower it changes vertically than horizontally
|
||||
#vertical_stretch: number;
|
||||
|
||||
constructor(seed: string, name: string, wavelength: number, amplitudes: number[], vertical_stretch = 1) {
|
||||
amplitudes.forEach((amplitude, i) => {
|
||||
if (amplitude !== 0) {
|
||||
this.#octaves.push({
|
||||
noise: named_noise_3d(seed, `${name}_${i}`),
|
||||
frequency: 2 ** i / wavelength,
|
||||
amplitude: amplitude / 2 ** i,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.#total = amplitudes.reduce((sum, amplitude, i) => sum + amplitude / 2 ** i, 0);
|
||||
this.#vertical_stretch = vertical_stretch;
|
||||
}
|
||||
|
||||
sample(x: number, y: number, z: number) {
|
||||
let value = 0;
|
||||
const sy = y / this.#vertical_stretch;
|
||||
for (const { noise, frequency, amplitude } of this.#octaves) {
|
||||
value += noise(x * frequency, sy * frequency, z * frequency) * amplitude;
|
||||
}
|
||||
return value / this.#total;
|
||||
}
|
||||
}
|
||||
|
||||
// maps a noise's bunched up values to an even spread from -1 to 1, so "the lowest 20%" is always below -0.6.
|
||||
// built from the noise's measured percentiles (every 5%, see tools/noise_quantiles.ts), which only depend
|
||||
// on its amplitudes
|
||||
export class Quantiles {
|
||||
#values: readonly number[];
|
||||
|
||||
constructor(values: readonly number[]) {
|
||||
this.#values = values;
|
||||
}
|
||||
|
||||
even(value: number) {
|
||||
const values = this.#values;
|
||||
const last = values.length - 1;
|
||||
if (value <= values[0]) return -1;
|
||||
if (value >= values[last]) return 1;
|
||||
let lo = 0;
|
||||
let hi = last;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (values[mid] <= value) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const t = (value - values[lo]) / (values[hi] - values[lo]);
|
||||
return ((lo + t) / last) * 2 - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return value < min ? min : value > max ? max : value;
|
||||
}
|
||||
|
||||
export function lerp(t: number, from: number, to: number) {
|
||||
return from + (to - from) * t;
|
||||
}
|
||||
|
||||
// 0 below edge0, 1 above edge1, smooth in between
|
||||
export function smoothstep(edge0: number, edge1: number, value: number) {
|
||||
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// fills a chunk with the overworld: the terrain's density sampled on a coarse grid and interpolated (minecraft's
|
||||
// noise cells), caves cut out of it, water up to sea level, then biomes and their surface blocks
|
||||
import { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, SEA_LEVEL } from "$/common/constants.ts";
|
||||
import { OctaveNoise2D } from "./noise.ts";
|
||||
import { OverworldTerrain, TerrainColumn } from "./terrain.ts";
|
||||
import { BIOME_TREES, grow_tree, tree_sites } from "./trees.ts";
|
||||
|
||||
// density is sampled every CELL_WIDTH blocks across and CELL_HEIGHT up, caves every CAVE_CELL_HEIGHT up since
|
||||
// tunnels are thinner than terrain features
|
||||
const CELL_WIDTH = 4;
|
||||
const CELL_HEIGHT = 8;
|
||||
const CAVE_CELL_HEIGHT = 4;
|
||||
const CORNERS = CHUNK_SIZE / CELL_WIDTH + 1;
|
||||
const TERRAIN_LEVELS = CHUNK_HEIGHT / CELL_HEIGHT + 1;
|
||||
const CAVE_LEVELS = CHUNK_HEIGHT / CAVE_CELL_HEIGHT + 1;
|
||||
|
||||
// surface blocks only go this far below the ground's height, so cave floors stay stone
|
||||
const SURFACE_REACH = 20;
|
||||
// a column is a cliff when its ground is this much higher or lower than a neighbor's
|
||||
const STEEP = 3.5;
|
||||
// snow covers the tops of everything above this, give or take
|
||||
const SNOW_LINE = SEA_LEVEL + 112;
|
||||
|
||||
// the blocks the overworld is made of, as numeric ids
|
||||
export interface OverworldBlocks {
|
||||
stone: number;
|
||||
dirt: number;
|
||||
grass: number;
|
||||
sand: number;
|
||||
snow: number;
|
||||
water: number;
|
||||
log: number;
|
||||
leaves: number;
|
||||
}
|
||||
|
||||
type Palette = "stone" | "dirt" | "grass" | "sand" | "snow";
|
||||
|
||||
// how a biome covers its ground, like minecraft's surface rules
|
||||
interface Surface {
|
||||
top: Palette;
|
||||
filler: Palette;
|
||||
filler_depth: number;
|
||||
// the ground's cover under water
|
||||
underwater_top: Palette;
|
||||
underwater_filler: Palette;
|
||||
// cliffs show bare stone instead of top
|
||||
bare_cliffs: boolean;
|
||||
// stripes of sand, stone and dirt down the cliffs, like badlands
|
||||
strata?: boolean;
|
||||
}
|
||||
|
||||
const GRASSY: Surface = {
|
||||
top: "grass",
|
||||
filler: "dirt",
|
||||
filler_depth: 3,
|
||||
underwater_top: "dirt",
|
||||
underwater_filler: "dirt",
|
||||
bare_cliffs: true,
|
||||
};
|
||||
const SANDY: Surface = {
|
||||
top: "sand",
|
||||
filler: "sand",
|
||||
filler_depth: 4,
|
||||
underwater_top: "sand",
|
||||
underwater_filler: "sand",
|
||||
bare_cliffs: false,
|
||||
};
|
||||
const SNOWY: Surface = { ...GRASSY, top: "snow" };
|
||||
const STONY: Surface = {
|
||||
...GRASSY,
|
||||
top: "stone",
|
||||
filler: "stone",
|
||||
underwater_top: "stone",
|
||||
underwater_filler: "stone",
|
||||
};
|
||||
const SEA_FLOOR: Surface = { ...SANDY, filler_depth: 3 };
|
||||
|
||||
// every biome the overworld has, and its surface. names follow minecraft's and terralith's
|
||||
export const BIOMES: Record<string, Surface> = {
|
||||
"bworld:deep_ocean": SEA_FLOOR,
|
||||
"bworld:deep_frozen_ocean": SEA_FLOOR,
|
||||
"bworld:deep_lukewarm_ocean": SEA_FLOOR,
|
||||
"bworld:ocean": SEA_FLOOR,
|
||||
"bworld:frozen_ocean": SEA_FLOOR,
|
||||
"bworld:warm_ocean": SEA_FLOOR,
|
||||
"bworld:river": { ...GRASSY, underwater_top: "sand", underwater_filler: "sand" },
|
||||
"bworld:frozen_river": { ...SNOWY, underwater_top: "sand", underwater_filler: "sand" },
|
||||
"bworld:beach": SANDY,
|
||||
"bworld:snowy_beach": { ...SANDY, top: "snow" },
|
||||
"bworld:stony_shore": STONY,
|
||||
"bworld:plains": GRASSY,
|
||||
"bworld:meadow": GRASSY,
|
||||
"bworld:forest": GRASSY,
|
||||
"bworld:dark_forest": GRASSY,
|
||||
"bworld:swamp": { ...GRASSY, bare_cliffs: false },
|
||||
"bworld:taiga": GRASSY,
|
||||
"bworld:snowy_plains": SNOWY,
|
||||
"bworld:snowy_taiga": SNOWY,
|
||||
"bworld:savanna": GRASSY,
|
||||
"bworld:jungle": GRASSY,
|
||||
"bworld:desert": SANDY,
|
||||
"bworld:alpine_highlands": GRASSY,
|
||||
"bworld:snowy_slopes": SNOWY,
|
||||
"bworld:stony_peaks": STONY,
|
||||
"bworld:jagged_peaks": { ...STONY, top: "snow" },
|
||||
"bworld:frozen_peaks": { ...SNOWY, filler: "stone" },
|
||||
"bworld:yosemite_cliffs": GRASSY,
|
||||
"bworld:snowy_cliffs": SNOWY,
|
||||
"bworld:painted_mountains": { ...SANDY, filler_depth: 2, bare_cliffs: true, strata: true },
|
||||
"bworld:stony_spires": GRASSY,
|
||||
"bworld:shattered_savanna": GRASSY,
|
||||
"bworld:skylands": GRASSY,
|
||||
};
|
||||
|
||||
// picks a column's biome from its climate and shape, like minecraft's multi noise biome source
|
||||
export function pick_biome(column: TerrainColumn, surface_y: number, steep: boolean): string {
|
||||
const { continentalness: c, erosion: e, pv, temperature: t, humidity: h } = column.climate;
|
||||
const frozen = t < -0.65;
|
||||
const cold = t < -0.3;
|
||||
const warm = t > 0.2;
|
||||
const hot = t > 0.55;
|
||||
|
||||
if (column.island && surface_y >= column.island.bottom) {
|
||||
return "bworld:skylands";
|
||||
}
|
||||
|
||||
if (surface_y < SEA_LEVEL - 1) {
|
||||
if (pv < -0.7 && c > -0.12) return frozen ? "bworld:frozen_river" : "bworld:river";
|
||||
if (c < -0.45) {
|
||||
return frozen ? "bworld:deep_frozen_ocean" : hot ? "bworld:deep_lukewarm_ocean" : "bworld:deep_ocean";
|
||||
}
|
||||
return frozen ? "bworld:frozen_ocean" : hot ? "bworld:warm_ocean" : "bworld:ocean";
|
||||
}
|
||||
if (c < -0.04 && surface_y <= SEA_LEVEL + 4) {
|
||||
return steep ? "bworld:stony_shore" : cold ? "bworld:snowy_beach" : "bworld:beach";
|
||||
}
|
||||
if (pv < -0.8 && c > -0.12 && surface_y <= SEA_LEVEL + 1) {
|
||||
return frozen ? "bworld:frozen_river" : "bworld:river";
|
||||
}
|
||||
|
||||
const above = surface_y - SEA_LEVEL;
|
||||
if (above > 105) {
|
||||
if (cold) return "bworld:frozen_peaks";
|
||||
return column.jaggedness > 0.35 ? "bworld:jagged_peaks" : "bworld:stony_peaks";
|
||||
}
|
||||
if (column.plateau > 0.5) {
|
||||
if (hot && h < 0.1) return "bworld:painted_mountains";
|
||||
return cold ? "bworld:snowy_cliffs" : "bworld:yosemite_cliffs";
|
||||
}
|
||||
if (column.shattered > 0.5) {
|
||||
return warm ? "bworld:shattered_savanna" : "bworld:stony_spires";
|
||||
}
|
||||
if (above > 60) {
|
||||
return cold ? "bworld:snowy_slopes" : "bworld:alpine_highlands";
|
||||
}
|
||||
if (above > 30 && e > -0.2 && !cold && h > -0.3) {
|
||||
return "bworld:meadow";
|
||||
}
|
||||
|
||||
if (frozen) return h > 0 ? "bworld:snowy_taiga" : "bworld:snowy_plains";
|
||||
if (cold) return h > 0 ? "bworld:taiga" : "bworld:plains";
|
||||
if (hot) return h < -0.2 ? "bworld:desert" : h < 0.4 ? "bworld:savanna" : "bworld:jungle";
|
||||
if (warm) return h < -0.3 ? "bworld:savanna" : h > 0.6 ? "bworld:jungle" : "bworld:forest";
|
||||
if (h > 0.5 && e > 0.4) return "bworld:swamp";
|
||||
return h < -0.35 ? "bworld:plains" : h < 0.45 ? "bworld:forest" : "bworld:dark_forest";
|
||||
}
|
||||
|
||||
interface SeedGenerators {
|
||||
terrain: OverworldTerrain;
|
||||
snow_line: OctaveNoise2D;
|
||||
strata: OctaveNoise2D;
|
||||
}
|
||||
|
||||
const generators = new Map<string, SeedGenerators>();
|
||||
|
||||
function generators_for(seed: string): SeedGenerators {
|
||||
let found = generators.get(seed);
|
||||
if (!found) {
|
||||
found = {
|
||||
terrain: new OverworldTerrain(seed),
|
||||
snow_line: new OctaveNoise2D(seed, "snow_line", 96, [1, 1]),
|
||||
strata: new OctaveNoise2D(seed, "strata", 128, [1]),
|
||||
};
|
||||
generators.set(seed, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// fills blocks (indexed y * CHUNK_AREA + z * CHUNK_SIZE + x) and each column's surface height and biome.
|
||||
// blocks trees put in other chunks go to spill
|
||||
export function generate_overworld(
|
||||
blocks: Uint32Array,
|
||||
heights: Int32Array,
|
||||
biomes: string[],
|
||||
chunk_x: number,
|
||||
chunk_z: number,
|
||||
seed: string,
|
||||
ids: OverworldBlocks,
|
||||
spill: (x: number, y: number, z: number, block: number) => void,
|
||||
) {
|
||||
const { terrain, snow_line, strata } = generators_for(seed);
|
||||
const x0 = chunk_x * CHUNK_SIZE;
|
||||
const z0 = chunk_z * CHUNK_SIZE;
|
||||
|
||||
// density at the corners of every cell
|
||||
const corner_columns: TerrainColumn[] = [];
|
||||
for (let cz = 0; cz < CORNERS; cz++) {
|
||||
for (let cx = 0; cx < CORNERS; cx++) {
|
||||
corner_columns.push(terrain.column(x0 + cx * CELL_WIDTH, z0 + cz * CELL_WIDTH));
|
||||
}
|
||||
}
|
||||
const solid = new Float32Array(CORNERS * CORNERS * TERRAIN_LEVELS);
|
||||
const caves = new Float32Array(CORNERS * CORNERS * CAVE_LEVELS);
|
||||
for (let i = 0; i < corner_columns.length; i++) {
|
||||
const column = corner_columns[i];
|
||||
const x = x0 + (i % CORNERS) * CELL_WIDTH;
|
||||
const z = z0 + Math.floor(i / CORNERS) * CELL_WIDTH;
|
||||
for (let level = 0; level < TERRAIN_LEVELS; level++) {
|
||||
solid[i * TERRAIN_LEVELS + level] = terrain.density(column, x, level * CELL_HEIGHT, z);
|
||||
}
|
||||
// caves only matter below the ground, skip the sky above it
|
||||
const cave_top = column.height + CAVE_CELL_HEIGHT;
|
||||
for (let level = 0; level < CAVE_LEVELS; level++) {
|
||||
const y = level * CAVE_CELL_HEIGHT;
|
||||
caves[i * CAVE_LEVELS + level] = y > cave_top ? 1 : terrain.cave(column, x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
// every column of the chunk plus a ring around it, for how steep the ground is
|
||||
const columns: TerrainColumn[] = [];
|
||||
const ring = CHUNK_SIZE + 2;
|
||||
for (let z = -1; z <= CHUNK_SIZE; z++) {
|
||||
for (let x = -1; x <= CHUNK_SIZE; x++) {
|
||||
columns.push(terrain.column(x0 + x, z0 + z));
|
||||
}
|
||||
}
|
||||
const column_at = (x: number, z: number) => columns[(z + 1) * ring + x + 1];
|
||||
|
||||
for (let z = 0; z < CHUNK_SIZE; z++) {
|
||||
for (let x = 0; x < CHUNK_SIZE; x++) {
|
||||
fill_column(x, z);
|
||||
}
|
||||
}
|
||||
grow_trees();
|
||||
|
||||
function grow_trees() {
|
||||
const place = (x: number, y: number, z: number, block: "log" | "leaves") => {
|
||||
if (y < 0 || y >= CHUNK_HEIGHT) return;
|
||||
const lx = x - x0;
|
||||
const lz = z - z0;
|
||||
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) {
|
||||
// the other chunk only takes it where it has air
|
||||
spill(x, y, z, ids[block]);
|
||||
return;
|
||||
}
|
||||
const i = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
|
||||
const current = blocks[i];
|
||||
if (current === 0 || (block === "log" && current === ids.leaves)) {
|
||||
blocks[i] = ids[block];
|
||||
}
|
||||
};
|
||||
|
||||
for (const site of tree_sites(seed, chunk_x, chunk_z)) {
|
||||
const column = (site.z - z0) * CHUNK_SIZE + (site.x - x0);
|
||||
const trees = BIOME_TREES[biomes[column]];
|
||||
if (!trees || site.rng.next() >= trees.chance) continue;
|
||||
|
||||
// on soil with air above it, so never under water or on bare rock
|
||||
const ground_y = heights[column];
|
||||
const ground = blocks[ground_y * CHUNK_AREA + column];
|
||||
const above = blocks[(ground_y + 1) * CHUNK_AREA + column];
|
||||
if ((ground !== ids.grass && ground !== ids.dirt && ground !== ids.snow) || above !== 0) continue;
|
||||
|
||||
const kind = trees.kinds[Math.floor(site.rng.next() * trees.kinds.length)];
|
||||
grow_tree(kind, site.x, ground_y + 1, site.z, site.rng, place);
|
||||
}
|
||||
}
|
||||
|
||||
function fill_column(x: number, z: number) {
|
||||
const cell_x = Math.min(Math.floor(x / CELL_WIDTH), CORNERS - 2);
|
||||
const cell_z = Math.min(Math.floor(z / CELL_WIDTH), CORNERS - 2);
|
||||
const tx = (x - cell_x * CELL_WIDTH) / CELL_WIDTH;
|
||||
const tz = (z - cell_z * CELL_WIDTH) / CELL_WIDTH;
|
||||
const c00 = cell_z * CORNERS + cell_x;
|
||||
const c10 = c00 + 1;
|
||||
const c01 = c00 + CORNERS;
|
||||
const c11 = c01 + 1;
|
||||
const w00 = (1 - tx) * (1 - tz);
|
||||
const w10 = tx * (1 - tz);
|
||||
const w01 = (1 - tx) * tz;
|
||||
const w11 = tx * tz;
|
||||
// one column through the corner grid, blended between its four corners
|
||||
const blend = (grid: Float32Array, levels: number, level: number) =>
|
||||
grid[c00 * levels + level] * w00 + grid[c10 * levels + level] * w10 +
|
||||
grid[c01 * levels + level] * w01 + grid[c11 * levels + level] * w11;
|
||||
|
||||
const is_solid = new Uint8Array(CHUNK_HEIGHT);
|
||||
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
const level = Math.min(Math.floor(y / CELL_HEIGHT), TERRAIN_LEVELS - 2);
|
||||
const t = (y - level * CELL_HEIGHT) / CELL_HEIGHT;
|
||||
let density = blend(solid, TERRAIN_LEVELS, level) * (1 - t) + blend(solid, TERRAIN_LEVELS, level + 1) * t;
|
||||
if (density > 0) {
|
||||
const cave_level = Math.min(Math.floor(y / CAVE_CELL_HEIGHT), CAVE_LEVELS - 2);
|
||||
const ct = (y - cave_level * CAVE_CELL_HEIGHT) / CAVE_CELL_HEIGHT;
|
||||
const cave = blend(caves, CAVE_LEVELS, cave_level) * (1 - ct) +
|
||||
blend(caves, CAVE_LEVELS, cave_level + 1) * ct;
|
||||
density = Math.min(density, cave);
|
||||
}
|
||||
is_solid[y] = density > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
const column = column_at(x, z);
|
||||
let surface_y = CHUNK_HEIGHT - 1;
|
||||
while (surface_y > 0 && !is_solid[surface_y]) surface_y--;
|
||||
|
||||
const ground = column.height;
|
||||
const neighbors = [column_at(x - 1, z), column_at(x + 1, z), column_at(x, z - 1), column_at(x, z + 1)];
|
||||
const steep = neighbors.some((neighbor) => Math.abs(neighbor.height - ground) >= STEEP);
|
||||
const biome = pick_biome(column, surface_y, steep);
|
||||
const surface = BIOMES[biome];
|
||||
const snow_y = SNOW_LINE + snow_line.sample(x0 + x, z0 + z) * 10;
|
||||
const strata_offset = strata.sample(x0 + x, z0 + z) * 4;
|
||||
const island_bottom = column.island ? column.island.bottom - 2 : Infinity;
|
||||
|
||||
heights[z * CHUNK_SIZE + x] = surface_y;
|
||||
biomes[z * CHUNK_SIZE + x] = biome;
|
||||
|
||||
const index = (y: number) => y * CHUNK_AREA + z * CHUNK_SIZE + x;
|
||||
// solid blocks since the last air going down, 0 is a block with air on top
|
||||
let depth = -1;
|
||||
// nothing but air (and sky islands) above so far: water fills it up to sea level
|
||||
let open_sky = true;
|
||||
// whether the ground's surface is under water, for its cover
|
||||
let underwater = false;
|
||||
for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) {
|
||||
if (!is_solid[y]) {
|
||||
depth = -1;
|
||||
if (open_sky && y < SEA_LEVEL) {
|
||||
blocks[index(y)] = ids.water;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
depth += 1;
|
||||
const in_island = y >= island_bottom;
|
||||
if (open_sky && !in_island) {
|
||||
open_sky = false;
|
||||
underwater = y < SEA_LEVEL - 1;
|
||||
}
|
||||
|
||||
// surface rules reach the ground and anything above it, not cave floors
|
||||
let block: Palette = "stone";
|
||||
if (y >= ground - SURFACE_REACH || in_island) {
|
||||
const wet = underwater && !in_island;
|
||||
if (surface.strata && depth > 0 && y > SEA_LEVEL) {
|
||||
block = strata_block(y + strata_offset);
|
||||
} else if (depth === 0) {
|
||||
if (wet) block = surface.underwater_top;
|
||||
else if (y >= snow_y && !in_island) block = steep ? "stone" : "snow";
|
||||
else block = steep && surface.bare_cliffs ? "stone" : surface.top;
|
||||
} else if (depth <= surface.filler_depth) {
|
||||
block = wet ? surface.underwater_filler : surface.filler;
|
||||
}
|
||||
}
|
||||
blocks[index(y)] = ids[block];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the bands down a painted mountain's cliffs
|
||||
function strata_block(y: number): Palette {
|
||||
const band = ((Math.floor(y / 3) % 6) + 6) % 6;
|
||||
return band === 1 || band === 4 ? "stone" : band === 3 ? "dirt" : "sand";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// minecraft's CubicSpline: a smooth curve through points, where a point's value can itself be a spline of another
|
||||
// input. that nesting is how its terrain (and terralith's) turns continentalness, erosion and peaks and valleys into
|
||||
// heights: a spline over continentalness whose points are splines over erosion, whose points are splines over pv
|
||||
|
||||
export interface SplineInputs {
|
||||
continentalness: number;
|
||||
erosion: number;
|
||||
pv: number;
|
||||
weirdness: number;
|
||||
}
|
||||
|
||||
export type SplineValue = number | Spline;
|
||||
|
||||
export interface SplinePoint {
|
||||
at: number;
|
||||
value: SplineValue;
|
||||
// the slope there, worked out from the neighbors when not given
|
||||
slope?: number;
|
||||
}
|
||||
|
||||
export class Spline {
|
||||
readonly input: keyof SplineInputs;
|
||||
#locations: number[];
|
||||
#values: SplineValue[];
|
||||
#slopes: number[];
|
||||
|
||||
constructor(input: keyof SplineInputs, points: SplinePoint[]) {
|
||||
this.input = input;
|
||||
this.#locations = points.map((point) => point.at);
|
||||
this.#values = points.map((point) => point.value);
|
||||
// catmull-rom slopes between the neighbors, flat at the ends and where values are splines
|
||||
this.#slopes = points.map((point, i) => {
|
||||
if (point.slope !== undefined) return point.slope;
|
||||
const before = points[i - 1];
|
||||
const after = points[i + 1];
|
||||
if (!before || !after || typeof before.value !== "number" || typeof after.value !== "number") {
|
||||
return 0;
|
||||
}
|
||||
return (after.value - before.value) / (after.at - before.at);
|
||||
});
|
||||
}
|
||||
|
||||
get(inputs: SplineInputs): number {
|
||||
const x = inputs[this.input];
|
||||
const locations = this.#locations;
|
||||
const last = locations.length - 1;
|
||||
|
||||
if (x <= locations[0]) {
|
||||
return value_of(this.#values[0], inputs) + this.#slopes[0] * (x - locations[0]);
|
||||
}
|
||||
if (x >= locations[last]) {
|
||||
return value_of(this.#values[last], inputs) + this.#slopes[last] * (x - locations[last]);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (locations[i + 1] < x) i++;
|
||||
const x0 = locations[i];
|
||||
const x1 = locations[i + 1];
|
||||
const width = x1 - x0;
|
||||
const t = (x - x0) / width;
|
||||
const y0 = value_of(this.#values[i], inputs);
|
||||
const y1 = value_of(this.#values[i + 1], inputs);
|
||||
// hermite interpolation, written the way minecraft does it
|
||||
const a = this.#slopes[i] * width - (y1 - y0);
|
||||
const b = -this.#slopes[i + 1] * width + (y1 - y0);
|
||||
return y0 + (y1 - y0) * t + t * (1 - t) * (a + (b - a) * t);
|
||||
}
|
||||
}
|
||||
|
||||
function value_of(value: SplineValue, inputs: SplineInputs) {
|
||||
return typeof value === "number" ? value : value.get(inputs);
|
||||
}
|
||||
|
||||
// a spline through evenly spaced values of one input
|
||||
export function spline(input: keyof SplineInputs, at: number[], values: SplineValue[]) {
|
||||
return new Spline(input, at.map((location, i) => ({ at: location, value: values[i] })));
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// the shape of the overworld, built the way minecraft 1.18+ (and terralith on top of it) does it:
|
||||
// large noises for continentalness, erosion and weirdness feed nested splines that give each column a target height,
|
||||
// how jagged its peaks are and how rough its ground is. a 3d density around that height decides what's solid,
|
||||
// and caves are cut out of it. terralith's flavor comes from the extra shapes: terraced plateaus with cliffs,
|
||||
// shattered hills full of overhangs, deep river valleys and gorges, jagged peaks and rare sky islands
|
||||
import { CHUNK_HEIGHT, SEA_LEVEL } from "$/common/constants.ts";
|
||||
import { clamp, lerp, OctaveNoise2D, OctaveNoise3D, Quantiles, smoothstep } from "./noise.ts";
|
||||
import { Spline, spline, SplineInputs } from "./spline.ts";
|
||||
|
||||
// measured with tools/noise_quantiles.ts
|
||||
const QUANTILES_6 = new Quantiles([
|
||||
-0.833,
|
||||
-0.386,
|
||||
-0.311,
|
||||
-0.256,
|
||||
-0.21,
|
||||
-0.17,
|
||||
-0.133,
|
||||
-0.098,
|
||||
-0.065,
|
||||
-0.032,
|
||||
0,
|
||||
0.032,
|
||||
0.065,
|
||||
0.098,
|
||||
0.133,
|
||||
0.17,
|
||||
0.21,
|
||||
0.256,
|
||||
0.311,
|
||||
0.386,
|
||||
0.833,
|
||||
]);
|
||||
const QUANTILES_4 = new Quantiles([
|
||||
-0.917,
|
||||
-0.48,
|
||||
-0.393,
|
||||
-0.328,
|
||||
-0.273,
|
||||
-0.222,
|
||||
-0.174,
|
||||
-0.13,
|
||||
-0.086,
|
||||
-0.042,
|
||||
0,
|
||||
0.042,
|
||||
0.086,
|
||||
0.13,
|
||||
0.174,
|
||||
0.222,
|
||||
0.273,
|
||||
0.328,
|
||||
0.393,
|
||||
0.48,
|
||||
0.917,
|
||||
]);
|
||||
const QUANTILES_3 = new Quantiles([
|
||||
-0.91,
|
||||
-0.468,
|
||||
-0.377,
|
||||
-0.308,
|
||||
-0.249,
|
||||
-0.199,
|
||||
-0.154,
|
||||
-0.113,
|
||||
-0.073,
|
||||
-0.036,
|
||||
0,
|
||||
0.036,
|
||||
0.073,
|
||||
0.113,
|
||||
0.154,
|
||||
0.199,
|
||||
0.249,
|
||||
0.308,
|
||||
0.377,
|
||||
0.468,
|
||||
0.91,
|
||||
]);
|
||||
const QUANTILES_2 = new Quantiles([
|
||||
-0.962,
|
||||
-0.536,
|
||||
-0.439,
|
||||
-0.368,
|
||||
-0.306,
|
||||
-0.248,
|
||||
-0.195,
|
||||
-0.146,
|
||||
-0.096,
|
||||
-0.046,
|
||||
0,
|
||||
0.046,
|
||||
0.096,
|
||||
0.146,
|
||||
0.195,
|
||||
0.248,
|
||||
0.306,
|
||||
0.368,
|
||||
0.439,
|
||||
0.536,
|
||||
0.962,
|
||||
]);
|
||||
const QUANTILES_TEMPERATURE = new Quantiles([
|
||||
-0.973,
|
||||
-0.6,
|
||||
-0.509,
|
||||
-0.44,
|
||||
-0.379,
|
||||
-0.319,
|
||||
-0.254,
|
||||
-0.19,
|
||||
-0.127,
|
||||
-0.063,
|
||||
0,
|
||||
0.063,
|
||||
0.127,
|
||||
0.19,
|
||||
0.254,
|
||||
0.319,
|
||||
0.379,
|
||||
0.44,
|
||||
0.509,
|
||||
0.6,
|
||||
0.973,
|
||||
]);
|
||||
|
||||
// blocks of height per unit of density, how soft the ground's surface is
|
||||
const THICKNESS = 20;
|
||||
// solid ground always ends here, and nothing reaches past the top of the world
|
||||
const TOP_SLIDE_START = CHUNK_HEIGHT - 24;
|
||||
const TOP_SLIDE_END = CHUNK_HEIGHT - 4;
|
||||
const MIN_CAVE_Y = 5;
|
||||
|
||||
// the climate at a column, every value spread evenly from -1 to 1
|
||||
export interface Climate {
|
||||
// ocean far below 0, coast around -0.2, further inland higher
|
||||
continentalness: number;
|
||||
// low is mountains, high is flat land
|
||||
erosion: number;
|
||||
// picks between variants, and its folded form pv
|
||||
weirdness: number;
|
||||
// peaks and valleys: -1 in a valley (rivers), 1 on a peak
|
||||
pv: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
}
|
||||
|
||||
// everything 2d about a column that the density needs, worked out once per column
|
||||
export interface TerrainColumn {
|
||||
climate: Climate;
|
||||
// where the ground's surface is before 3d noise, in blocks
|
||||
height: number;
|
||||
// how much 3d noise moves the ground, in units of density
|
||||
roughness: number;
|
||||
// 0 to 1, how much of it are terraced plateaus and shattered hills
|
||||
plateau: number;
|
||||
shattered: number;
|
||||
// 0 to 1, how pointy its peaks are
|
||||
jaggedness: number;
|
||||
// a sky island above it, when there is one
|
||||
island?: { top: number; bottom: number };
|
||||
}
|
||||
|
||||
// minecraft's peaks and valleys: weirdness folded so both its ends are peaks and its middle a valley
|
||||
export function peaks_and_valleys(weirdness: number) {
|
||||
return 1 - Math.abs(3 * Math.abs(weirdness) - 2);
|
||||
}
|
||||
|
||||
// the valley value holds until -0.85, so rivers have a flat bottom and some width
|
||||
const PV_POINTS = [-1, -0.85, -0.65, -0.35, 0, 0.45, 0.8, 1];
|
||||
const EROSION_POINTS = [-1, -0.6, -0.3, 0, 0.3, 0.6, 1];
|
||||
|
||||
// the heights over land at one level of continentalness, relative to sea level. base lifts everything, mountains
|
||||
// scales how tall they get. each erosion gets a spline over peaks and valleys: a valley value, then how far above
|
||||
// base the ground rises towards the peaks
|
||||
function land(base: number, mountains: number): Spline {
|
||||
const row = (valley: number, rises: number[]) =>
|
||||
spline("pv", PV_POINTS, [valley, valley, ...rises.map((rise) => base + rise * mountains)]);
|
||||
return spline("erosion", EROSION_POINTS, [
|
||||
// barely eroded: huge mountains, their valleys are gorges high above the sea
|
||||
row(base + 14 * mountains, [22, 44, 66, 88, 104, 110]),
|
||||
row(base + 8 * mountains, [14, 28, 42, 54, 62, 66]),
|
||||
// hills and highlands, their valleys carry rivers
|
||||
row(-4, [8, 20, 28, 36, 42, 44]),
|
||||
row(-5, [4, 10, 15, 20, 23, 24]),
|
||||
row(-5, [2, 6, 9, 12, 13, 14]),
|
||||
// worn flat: plains and wetlands
|
||||
row(-4, [1, 3, 5, 7, 8, 8]),
|
||||
row(-3, [0, 1, 2, 3, 3, 3]),
|
||||
]);
|
||||
}
|
||||
|
||||
// target height above sea level
|
||||
const OFFSET = spline(
|
||||
"continentalness",
|
||||
[-1, -0.55, -0.3, -0.18, -0.12, -0.04, 0.2, 0.5, 1],
|
||||
[-46, -32, -18, -8, -2, land(1, 0.3), land(3, 0.65), land(8, 1), land(14, 1.15)],
|
||||
);
|
||||
|
||||
// how pointy peaks get, 0 to 1. only tall, barely eroded mountains have them
|
||||
const JAGGEDNESS = spline("erosion", [-1, -0.6, -0.3, 0], [
|
||||
spline("pv", [-0.2, 0.3, 1], [0, 0.6, 1]),
|
||||
spline("pv", [0, 0.5, 1], [0, 0.4, 0.7]),
|
||||
spline("pv", [0.3, 0.8, 1], [0, 0.2, 0.3]),
|
||||
0,
|
||||
]);
|
||||
|
||||
// 3d noise strength: mountains are rougher than plains
|
||||
const ROUGHNESS = spline("erosion", [-1, -0.5, 0, 0.5, 1], [0.32, 0.22, 0.14, 0.1, 0.06]);
|
||||
|
||||
export class OverworldTerrain {
|
||||
#continentalness: OctaveNoise2D;
|
||||
#erosion: OctaveNoise2D;
|
||||
#weirdness: OctaveNoise2D;
|
||||
#temperature: OctaveNoise2D;
|
||||
#humidity: OctaveNoise2D;
|
||||
#warp_x: OctaveNoise2D;
|
||||
#warp_z: OctaveNoise2D;
|
||||
#jagged: OctaveNoise2D;
|
||||
#plateau: OctaveNoise2D;
|
||||
#shattered: OctaveNoise2D;
|
||||
#sky: OctaveNoise2D;
|
||||
#island_shape: OctaveNoise2D;
|
||||
#island_height: OctaveNoise2D;
|
||||
#ground: OctaveNoise3D;
|
||||
#island_noise: OctaveNoise3D;
|
||||
#cheese: OctaveNoise3D;
|
||||
#spaghetti_a: OctaveNoise3D;
|
||||
#spaghetti_b: OctaveNoise3D;
|
||||
#entrances: OctaveNoise2D;
|
||||
|
||||
constructor(seed: string) {
|
||||
this.#continentalness = new OctaveNoise2D(seed, "continentalness", 1024, [1, 1, 2, 2, 1, 1]);
|
||||
this.#erosion = new OctaveNoise2D(seed, "erosion", 768, [1, 1, 0, 1, 1]);
|
||||
this.#weirdness = new OctaveNoise2D(seed, "weirdness", 256, [1, 2, 1]);
|
||||
this.#temperature = new OctaveNoise2D(seed, "temperature", 1536, [1.5, 0, 1]);
|
||||
this.#humidity = new OctaveNoise2D(seed, "humidity", 512, [1, 1]);
|
||||
this.#warp_x = new OctaveNoise2D(seed, "warp_x", 200, [1, 1]);
|
||||
this.#warp_z = new OctaveNoise2D(seed, "warp_z", 200, [1, 1]);
|
||||
this.#jagged = new OctaveNoise2D(seed, "jagged", 48, [1, 1]);
|
||||
this.#plateau = new OctaveNoise2D(seed, "plateau", 640, [1, 1]);
|
||||
this.#shattered = new OctaveNoise2D(seed, "shattered", 512, [1, 1]);
|
||||
this.#sky = new OctaveNoise2D(seed, "sky", 900, [1, 1]);
|
||||
this.#island_shape = new OctaveNoise2D(seed, "island_shape", 56, [1, 1]);
|
||||
this.#island_height = new OctaveNoise2D(seed, "island_height", 300, [1, 1]);
|
||||
this.#ground = new OctaveNoise3D(seed, "ground", 64, [1, 1, 0.5], 1.2);
|
||||
this.#island_noise = new OctaveNoise3D(seed, "island_noise", 24, [1, 1]);
|
||||
this.#cheese = new OctaveNoise3D(seed, "cheese", 80, [1, 0.5], 0.6);
|
||||
this.#spaghetti_a = new OctaveNoise3D(seed, "spaghetti_a", 64, [1], 0.8);
|
||||
this.#spaghetti_b = new OctaveNoise3D(seed, "spaghetti_b", 64, [1], 0.8);
|
||||
this.#entrances = new OctaveNoise2D(seed, "entrances", 90, [1, 1]);
|
||||
}
|
||||
|
||||
climate(x: number, z: number): Climate {
|
||||
// a small warp, so coasts and biome edges aren't the noise's smooth blobs
|
||||
const wx = x + this.#warp_x.sample(x, z) * 24;
|
||||
const wz = z + this.#warp_z.sample(x, z) * 24;
|
||||
const weirdness = QUANTILES_3.even(this.#weirdness.sample(wx, wz));
|
||||
return {
|
||||
continentalness: QUANTILES_6.even(this.#continentalness.sample(wx, wz)),
|
||||
erosion: QUANTILES_4.even(this.#erosion.sample(wx, wz)),
|
||||
weirdness,
|
||||
pv: peaks_and_valleys(weirdness),
|
||||
temperature: QUANTILES_TEMPERATURE.even(this.#temperature.sample(wx, wz)),
|
||||
humidity: QUANTILES_2.even(this.#humidity.sample(wx, wz)),
|
||||
};
|
||||
}
|
||||
|
||||
column(x: number, z: number): TerrainColumn {
|
||||
const climate = this.climate(x, z);
|
||||
const { continentalness: c, erosion: e } = climate;
|
||||
const inputs: SplineInputs = climate;
|
||||
const inland = smoothstep(-0.1, 0.3, c);
|
||||
|
||||
let height = OFFSET.get(inputs);
|
||||
|
||||
// jagged peaks: sharp ridges pushed up from the tallest mountains
|
||||
const jaggedness = clamp(JAGGEDNESS.get(inputs), 0, 1) * inland;
|
||||
if (jaggedness > 0) {
|
||||
const ridge = 1 - Math.abs(this.#jagged.sample(x, z));
|
||||
height += jaggedness * 26 * ridge * ridge;
|
||||
}
|
||||
|
||||
// terraced plateaus: raised land cut into flat benches and cliffs, like terralith's
|
||||
// yosemite cliffs and painted mountains
|
||||
const plateau = smoothstep(0.35, 0.55, QUANTILES_2.even(this.#plateau.sample(x, z))) *
|
||||
smoothstep(-0.75, -0.45, e) * (1 - smoothstep(0.1, 0.4, e)) * smoothstep(-0.05, 0.1, c);
|
||||
if (plateau > 0 && climate.pv > -0.8) {
|
||||
const raised = height + 20 * plateau;
|
||||
height = lerp(plateau, height, terrace(raised, 16));
|
||||
}
|
||||
|
||||
// shattered hills: ground broken up by strong 3d noise into overhangs, arches and spires
|
||||
const shattered = smoothstep(0.55, 0.75, QUANTILES_2.even(this.#shattered.sample(x, z))) *
|
||||
smoothstep(-0.6, -0.3, e) * (1 - smoothstep(0.3, 0.5, e)) * smoothstep(-0.05, 0.1, c);
|
||||
|
||||
const roughness = ROUGHNESS.get(inputs) + shattered * 0.9;
|
||||
|
||||
return {
|
||||
climate,
|
||||
height: SEA_LEVEL + height,
|
||||
roughness,
|
||||
plateau,
|
||||
shattered,
|
||||
jaggedness,
|
||||
island: this.#island(x, z),
|
||||
};
|
||||
}
|
||||
|
||||
// skylands: rare regions of floating islands, flat topped with long hanging undersides
|
||||
#island(x: number, z: number): TerrainColumn["island"] {
|
||||
const region = smoothstep(0.9, 0.97, QUANTILES_2.even(this.#sky.sample(x, z)));
|
||||
if (region <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const shape = region * smoothstep(0.05, 0.35, this.#island_shape.sample(x, z));
|
||||
if (shape <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const center = 178 + this.#island_height.sample(x, z) * 24;
|
||||
return { top: center + 2 + 6 * shape, bottom: center - 4 - 34 * shape * Math.sqrt(shape) };
|
||||
}
|
||||
|
||||
// positive is solid, before caves
|
||||
density(column: TerrainColumn, x: number, y: number, z: number) {
|
||||
let density = (column.height - y) / THICKNESS + column.roughness * this.#ground.sample(x, y, z);
|
||||
|
||||
const island = column.island;
|
||||
if (island && y > island.bottom - 4 && y < island.top + 4) {
|
||||
const solid = Math.min((island.top - y) / 3, (y - island.bottom) / 6) +
|
||||
0.4 * this.#island_noise.sample(x, y, z);
|
||||
density = Math.max(density, solid);
|
||||
}
|
||||
|
||||
if (y > TOP_SLIDE_START) {
|
||||
density -= smoothstep(TOP_SLIDE_START, TOP_SLIDE_END, y) * 4;
|
||||
}
|
||||
if (y < 1) {
|
||||
density = Math.max(density, 1);
|
||||
}
|
||||
return density;
|
||||
}
|
||||
|
||||
// negative where a cave is. caves stay under the ground's skin except at entrances, and never break
|
||||
// the floor of oceans and rivers
|
||||
cave(column: TerrainColumn, x: number, y: number, z: number) {
|
||||
if (y < MIN_CAVE_Y) {
|
||||
return 1;
|
||||
}
|
||||
const depth = column.height - y;
|
||||
if (column.height < SEA_LEVEL + 3 && depth < 14) {
|
||||
return 1;
|
||||
}
|
||||
if (depth < 7 && this.#entrances.sample(x, z) < 0.5) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// cheese caves: big open caverns
|
||||
const cheese = (0.52 - this.#cheese.sample(x, y, z)) * 4;
|
||||
// spaghetti caves: long winding tunnels where two noises are both near zero
|
||||
const a = Math.abs(this.#spaghetti_a.sample(x, y, z));
|
||||
const b = Math.abs(this.#spaghetti_b.sample(x, y, z));
|
||||
const spaghetti = (Math.max(a, b) - 0.07) * 8;
|
||||
return Math.min(cheese, spaghetti);
|
||||
}
|
||||
}
|
||||
|
||||
// flat benches every step blocks, joined by steep cliffs
|
||||
function terrace(height: number, step: number) {
|
||||
const k = height / step;
|
||||
const floor = Math.floor(k);
|
||||
return (floor + smoothstep(0.4, 0.6, k - floor)) * step;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// trees, part of the terrain pass. where they grow can't depend on which chunk generates first, so it only
|
||||
// depends on the seed: every CELL x CELL cell has one candidate spot and a random priority, and a candidate becomes a
|
||||
// tree only if no other candidate within MIN_DISTANCE has a higher priority (then its biome may still say no). that
|
||||
// spreads trees out like poisson disk sampling: never closer than MIN_DISTANCE, but without lining up in a grid.
|
||||
// a tree is placed by the chunk its trunk is in, leaves that reach into the next chunk go through the spills like any
|
||||
// other block there
|
||||
import { Alea } from "@paulaboks/rng";
|
||||
import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts";
|
||||
|
||||
const CELL = 4;
|
||||
// trunks are at least this far apart
|
||||
const MIN_DISTANCE = 4;
|
||||
// how many cells out a candidate can be and still be within MIN_DISTANCE
|
||||
const REACH = Math.ceil(MIN_DISTANCE / CELL);
|
||||
// the widest a canopy layer gets
|
||||
const MAX_CANOPY = 3;
|
||||
|
||||
export type TreeKind = "oak" | "big_oak" | "spruce" | "jungle" | "acacia";
|
||||
|
||||
// how likely each biome's cells are to have a tree, and which kinds grow there
|
||||
export const BIOME_TREES: Record<string, { chance: number; kinds: TreeKind[] }> = {
|
||||
"bworld:forest": { chance: 0.85, kinds: ["oak", "oak", "big_oak"] },
|
||||
"bworld:dark_forest": { chance: 1, kinds: ["big_oak", "big_oak", "oak"] },
|
||||
"bworld:plains": { chance: 0.05, kinds: ["oak"] },
|
||||
"bworld:meadow": { chance: 0.04, kinds: ["oak"] },
|
||||
"bworld:swamp": { chance: 0.4, kinds: ["big_oak"] },
|
||||
"bworld:river": { chance: 0.05, kinds: ["oak"] },
|
||||
"bworld:taiga": { chance: 0.75, kinds: ["spruce"] },
|
||||
"bworld:snowy_taiga": { chance: 0.6, kinds: ["spruce"] },
|
||||
"bworld:snowy_plains": { chance: 0.03, kinds: ["spruce"] },
|
||||
"bworld:snowy_slopes": { chance: 0.06, kinds: ["spruce"] },
|
||||
"bworld:snowy_cliffs": { chance: 0.1, kinds: ["spruce"] },
|
||||
"bworld:alpine_highlands": { chance: 0.2, kinds: ["spruce", "spruce", "oak"] },
|
||||
"bworld:yosemite_cliffs": { chance: 0.2, kinds: ["spruce", "oak"] },
|
||||
"bworld:stony_spires": { chance: 0.12, kinds: ["spruce"] },
|
||||
"bworld:savanna": { chance: 0.15, kinds: ["acacia", "acacia", "oak"] },
|
||||
"bworld:shattered_savanna": { chance: 0.12, kinds: ["acacia"] },
|
||||
"bworld:jungle": { chance: 1, kinds: ["jungle", "jungle", "big_oak"] },
|
||||
"bworld:skylands": { chance: 0.3, kinds: ["oak", "big_oak"] },
|
||||
};
|
||||
|
||||
export interface TreeSite {
|
||||
x: number;
|
||||
z: number;
|
||||
// seeded from the cell, what's left of it picks the tree
|
||||
rng: Alea;
|
||||
}
|
||||
|
||||
interface Candidate extends TreeSite {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
// every cell's candidate, from the seed and the cell alone
|
||||
function candidate(seed: string, cell_x: number, cell_z: number): Candidate {
|
||||
const rng = new Alea(`${seed}_tree_${cell_x}_${cell_z}`);
|
||||
const x = cell_x * CELL + Math.floor(rng.next() * CELL);
|
||||
const z = cell_z * CELL + Math.floor(rng.next() * CELL);
|
||||
return { x, z, priority: rng.next(), rng };
|
||||
}
|
||||
|
||||
// the spots in the chunk where a tree may grow, before biome and ground are checked
|
||||
export function tree_sites(seed: string, chunk_x: number, chunk_z: number): TreeSite[] {
|
||||
const cells = CHUNK_SIZE / CELL;
|
||||
const first_x = chunk_x * cells - REACH;
|
||||
const first_z = chunk_z * cells - REACH;
|
||||
const size = cells + 2 * REACH;
|
||||
const candidates: Candidate[] = [];
|
||||
for (let cz = 0; cz < size; cz++) {
|
||||
for (let cx = 0; cx < size; cx++) {
|
||||
candidates.push(candidate(seed, first_x + cx, first_z + cz));
|
||||
}
|
||||
}
|
||||
|
||||
const sites: TreeSite[] = [];
|
||||
for (let cz = REACH; cz < REACH + cells; cz++) {
|
||||
for (let cx = REACH; cx < REACH + cells; cx++) {
|
||||
const site = candidates[cz * size + cx];
|
||||
let wins = true;
|
||||
for (let dz = -REACH; dz <= REACH && wins; dz++) {
|
||||
for (let dx = -REACH; dx <= REACH; dx++) {
|
||||
const other = candidates[(cz + dz) * size + cx + dx];
|
||||
if (other === site) continue;
|
||||
const distance_sq = (other.x - site.x) ** 2 + (other.z - site.z) ** 2;
|
||||
if (distance_sq < MIN_DISTANCE * MIN_DISTANCE && other.priority > site.priority) {
|
||||
wins = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wins) sites.push(site);
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
// log replaces anything that isn't ground, leaves only fill air
|
||||
export type PlaceBlock = (x: number, y: number, z: number, block: "log" | "leaves") => void;
|
||||
|
||||
// grows a tree with its trunk's bottom at x, y, z. returns false when it wouldn't fit under the top of the world
|
||||
export function grow_tree(kind: TreeKind, x: number, y: number, z: number, rng: Alea, place: PlaceBlock) {
|
||||
const random = (min: number, max: number) => min + Math.floor(rng.next() * (max - min + 1));
|
||||
const trunk = (height: number) => {
|
||||
for (let i = 0; i < height; i++) place(x, y + i, z, "log");
|
||||
};
|
||||
// a square layer of leaves, its corners left out at random like minecraft's
|
||||
const layer = (ly: number, radius: number, corners: boolean) => {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
for (let dz = -radius; dz <= radius; dz++) {
|
||||
const corner = Math.abs(dx) === radius && Math.abs(dz) === radius;
|
||||
if (corner && radius > 0 && (!corners || rng.next() < 0.5)) continue;
|
||||
place(x + dx, ly, z + dz, "leaves");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let top: number;
|
||||
switch (kind) {
|
||||
case "oak": {
|
||||
const height = random(4, 6);
|
||||
top = y + height + 1;
|
||||
if (top >= CHUNK_HEIGHT - 1) return false;
|
||||
trunk(height);
|
||||
layer(y + height - 2, 2, true);
|
||||
layer(y + height - 1, 2, true);
|
||||
layer(y + height, 1, true);
|
||||
layer(y + height + 1, 1, false);
|
||||
break;
|
||||
}
|
||||
case "big_oak": {
|
||||
const height = random(6, 8);
|
||||
top = y + height + 1;
|
||||
if (top >= CHUNK_HEIGHT - 1) return false;
|
||||
trunk(height);
|
||||
layer(y + height - 3, 2, true);
|
||||
layer(y + height - 2, 3, false);
|
||||
layer(y + height - 1, 3, true);
|
||||
layer(y + height, 2, true);
|
||||
layer(y + height + 1, 1, false);
|
||||
break;
|
||||
}
|
||||
case "spruce": {
|
||||
const height = random(7, 10);
|
||||
top = y + height + 1;
|
||||
if (top >= CHUNK_HEIGHT - 1) return false;
|
||||
trunk(height);
|
||||
// a cone of layers getting wider going down, every other one narrower, like minecraft's spruce
|
||||
place(x, y + height + 1, z, "leaves");
|
||||
layer(y + height, 1, false);
|
||||
let radius = 1;
|
||||
for (let ly = y + height - 1; ly >= y + 2; ly--) {
|
||||
radius = radius >= 2 + Math.floor((y + height - ly) / 4) ? 1 : radius + 1;
|
||||
layer(ly, Math.min(radius, MAX_CANOPY), false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "jungle": {
|
||||
const height = random(9, 13);
|
||||
top = y + height + 1;
|
||||
if (top >= CHUNK_HEIGHT - 1) return false;
|
||||
trunk(height);
|
||||
layer(y + height - 2, 3, false);
|
||||
layer(y + height - 1, 3, true);
|
||||
layer(y + height, 2, true);
|
||||
layer(y + height + 1, 1, false);
|
||||
break;
|
||||
}
|
||||
case "acacia": {
|
||||
// a short trunk that leans one way at the top, under a flat, wide canopy
|
||||
const height = random(4, 5);
|
||||
top = y + height + 2;
|
||||
if (top >= CHUNK_HEIGHT - 1) return false;
|
||||
trunk(height);
|
||||
const lean_x = random(-1, 1);
|
||||
const lean_z = lean_x === 0 ? (rng.next() < 0.5 ? -1 : 1) : 0;
|
||||
const cx = x + lean_x;
|
||||
const cz = z + lean_z;
|
||||
place(cx, y + height, cz, "log");
|
||||
for (let dx = -MAX_CANOPY; dx <= MAX_CANOPY; dx++) {
|
||||
for (let dz = -MAX_CANOPY; dz <= MAX_CANOPY; dz++) {
|
||||
if (Math.abs(dx) + Math.abs(dz) <= 4 && !(Math.abs(dx) === 3 && Math.abs(dz) === 3)) {
|
||||
place(cx + dx, y + height + 1, cz + dz, "leaves");
|
||||
}
|
||||
if (Math.abs(dx) + Math.abs(dz) <= 2) {
|
||||
place(cx + dx, y + height + 2, cz + dz, "leaves");
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -80,3 +80,633 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
# Voxel libre
|
||||
|
||||
https://github.com/VoxeLibre/VoxeLibre
|
||||
|
||||
License:
|
||||
|
||||
```
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
```
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"block": {
|
||||
"id": "bworld:chest",
|
||||
"textures": "bworld:planks",
|
||||
"collision": false,
|
||||
"mining": {
|
||||
"toughness": 8,
|
||||
"tool": "axe"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"block": {
|
||||
"id": "bworld:dirt",
|
||||
"textures": "bworld:dirt",
|
||||
"collision": false,
|
||||
"mining": {
|
||||
"toughness": 2,
|
||||
"tool": "shovel"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"block": {
|
||||
"id": "bworld:glass",
|
||||
"textures": "bworld:glass",
|
||||
"transparent": true,
|
||||
"render_layer": "translucent",
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "pickaxe"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"bottom": "bworld:dirt",
|
||||
"side": "bworld:grass_side"
|
||||
},
|
||||
"collision": false,
|
||||
"mining": {
|
||||
"toughness": 2,
|
||||
"tool": "shovel"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"top": "bworld:hoed_dirt",
|
||||
"bottom": "bworld:dirt"
|
||||
},
|
||||
"collision": false,
|
||||
"mining": {
|
||||
"toughness": 5,
|
||||
"tool": "shovel"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"block": {
|
||||
"id": "bworld:leaves",
|
||||
"textures": "bworld:leaves",
|
||||
"transparent": true,
|
||||
"render_layer": "cutout",
|
||||
"light_opacity": 1,
|
||||
"mining": {
|
||||
"toughness": 3,
|
||||
"tool": "hoe"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:light",
|
||||
"textures": "bworld:light",
|
||||
"light_emission": 14
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"block": {
|
||||
"id": "bworld:water",
|
||||
"textures": "bworld:water",
|
||||
"transparent": true,
|
||||
"render_layer": "translucent",
|
||||
"light_opacity": 1,
|
||||
"alpha": 0.8,
|
||||
"collision": false,
|
||||
"item": false,
|
||||
|
||||
|
After Width: | Height: | Size: 112 B |
|
After Width: | Height: | Size: 284 B |
|
After Width: | Height: | Size: 298 B |
|
After Width: | Height: | Size: 283 B |
|
After Width: | Height: | Size: 320 B |
|
After Width: | Height: | Size: 164 B |
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 238 B |