Compare commits

...
15 Commits
Author SHA1 Message Date
paula ef25e66f08 Getting some textures from voxellibre 2026-09-25 17:58:05 -03:00
paula a469202959 Credits page 2026-09-25 17:52:59 -03:00
paula e6f5db8a89 19 blocks 2026-09-25 17:50:31 -03:00
paula fdd236071e Tree generation 2026-09-25 17:41:36 -03:00
paula ea9d821047 Tweak gravity and jumping 2026-09-25 17:30:52 -03:00
paula 05f80c81ad Remove collision from water 2026-09-25 17:21:46 -03:00
paula d07528bd0e Better world generation 2026-09-25 17:14:26 -03:00
paula c750321ced Main menu 2026-09-25 16:51:23 -03:00
paula d107405f62 Q to drop 2026-09-25 16:42:19 -03:00
paula a254b96f65 Item entities 2026-09-25 16:26:45 -03:00
paula 5fb23cf404 Change how ticking works 2026-09-25 16:13:38 -03:00
paula 213363d0be No more ecs 2026-09-25 16:05:25 -03:00
paula 4539898c58 Lighting system 2026-09-25 15:40:09 -03:00
paula 25f8c683bb Fix dark lines when viewing blocks from far away 2026-09-25 15:22:33 -03:00
paula f8d406dcf0 Fix transparent blocks 2026-09-25 15:01:03 -03:00
123 changed files with 6259 additions and 2679 deletions
+38 -25
View File
@@ -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.
+14 -2
View File
@@ -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) {
-97
View File
@@ -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>`;
}
+25
View File
@@ -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;
}
}
+257
View File
@@ -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 });
}
}
}
-73
View File
@@ -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();
}
}
}
-15
View File
@@ -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;
}
-13
View File
@@ -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;
}
}
-20
View File
@@ -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;
}
}
-29
View File
@@ -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";
}
-87
View File
@@ -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;
}
}
}
-18
View File
@@ -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;
}
}
+87
View File
@@ -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;
}
}
+83
View File
@@ -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,
};
}
}
+94
View File
@@ -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,
});
}
}
+12
View File
@@ -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;
}
}
+66
View File
@@ -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];
}
-35
View File
@@ -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);
}
+97
View File
@@ -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 ?? "");
}
}
+38
View File
@@ -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;
}
}
}
+93
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
+45
View File
@@ -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();
}
}
}
+37
View File
@@ -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);
}
}
+15 -87
View File
@@ -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();
}
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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;
-21
View File
@@ -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);
}
}
+66
View File
@@ -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],
);
}
}
+50
View File
@@ -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 {}
}
+209
View File
@@ -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;
}
+182
View File
@@ -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);
}
}
}
+24 -4
View File
@@ -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`,
+10
View File
@@ -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
+118 -61
View File
@@ -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();
-19
View File
@@ -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);
}
+24 -16
View File
@@ -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;
},
+11 -47
View File
@@ -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") ?? "";
}
+30
View File
@@ -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",
];
}
+119
View File
@@ -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;
}
}
}
-51
View File
@@ -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;
}
+256 -16
View File
@@ -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]);
+31
View File
@@ -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);
}
}
}
+141
View File
@@ -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";
}
+139
View File
@@ -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);
}
-101
View File
@@ -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;
}
}
-59
View File
@@ -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();
}
}
}
-122
View File
@@ -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,
});
}
}
}
-205
View File
@@ -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 });
}
}
}
}
-71
View File
@@ -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);
}
}
-33
View File
@@ -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);
}
-53
View File
@@ -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;
}
}
-120
View File
@@ -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,
);
}
}
}
-54
View File
@@ -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;
}
}
}
-26
View File
@@ -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();
}
}
}
}
}
-49
View File
@@ -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",
);
}
}
}
}
-64
View File
@@ -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);
}
}
}
+49 -6
View File
@@ -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;
};
+308 -352
View File
@@ -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;
}
+176
View File
@@ -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];
}
+158
View File
@@ -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;
}
-18
View File
@@ -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);
}
}
-14
View File
@@ -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;
}
}
+8 -1
View File
@@ -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 },
-9
View File
@@ -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;
}
-28
View File
@@ -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();
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from "./component.ts";
export * from "./entity.ts";
export * from "./system.ts";
export * from "./world.ts";
-5
View File
@@ -1,5 +0,0 @@
import type { World } from "./world.ts";
export abstract class System {
abstract update(world: World, delta: number): void;
}
-80
View File
@@ -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;
}
}
+23 -1
View File
@@ -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;
+29 -313
View File
@@ -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);
+1
View File
@@ -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;
+31 -4
View File
@@ -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)) {
+3 -1
View File
@@ -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
+96
View File
@@ -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;
}
+22 -1
View File
@@ -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 }
-18
View File
@@ -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;
}
}
}
}
+130
View File
@@ -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);
}
+373
View File
@@ -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";
}
+77
View File
@@ -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] })));
}
+373
View File
@@ -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;
}
+192
View File
@@ -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;
}
+630
View File
@@ -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
```
-1
View File
@@ -3,7 +3,6 @@
"block": {
"id": "bworld:chest",
"textures": "bworld:planks",
"collision": false,
"mining": {
"toughness": 8,
"tool": "axe"
-1
View File
@@ -3,7 +3,6 @@
"block": {
"id": "bworld:dirt",
"textures": "bworld:dirt",
"collision": false,
"mining": {
"toughness": 2,
"tool": "shovel"
+1 -1
View File
@@ -3,7 +3,7 @@
"block": {
"id": "bworld:glass",
"textures": "bworld:glass",
"transparent": true,
"render_layer": "translucent",
"mining": {
"toughness": 3,
"tool": "pickaxe"
-1
View File
@@ -7,7 +7,6 @@
"bottom": "bworld:dirt",
"side": "bworld:grass_side"
},
"collision": false,
"mining": {
"toughness": 2,
"tool": "shovel"
-1
View File
@@ -7,7 +7,6 @@
"top": "bworld:hoed_dirt",
"bottom": "bworld:dirt"
},
"collision": false,
"mining": {
"toughness": 5,
"tool": "shovel"
+2 -1
View File
@@ -3,7 +3,8 @@
"block": {
"id": "bworld:leaves",
"textures": "bworld:leaves",
"transparent": true,
"render_layer": "cutout",
"light_opacity": 1,
"mining": {
"toughness": 3,
"tool": "hoe"
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"block": {
"id": "bworld:light",
"textures": "bworld:light",
"light_emission": 14
}
}
+2 -1
View File
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 238 B

Some files were not shown because too many files have changed in this diff Show More