This commit is contained in:
2026-09-24 23:28:01 -03:00
parent 79faa556de
commit bb42dd662e
73 changed files with 2349 additions and 34 deletions
+251 -26
View File
@@ -14,6 +14,7 @@ too, since the client is already a web page.
## Contents ## Contents
- [Overview](#overview) - [Overview](#overview)
- [Creating a mod](#creating-a-mod)
- [Mod layout](#mod-layout) - [Mod layout](#mod-layout)
- [manifest.json](#manifestjson) - [manifest.json](#manifestjson)
- [Identifiers](#identifiers) - [Identifiers](#identifiers)
@@ -29,6 +30,7 @@ too, since the client is already a web page.
- [Delivery to clients](#delivery-to-clients) - [Delivery to clients](#delivery-to-clients)
- [Security](#security) - [Security](#security)
- [Example mod](#example-mod) - [Example mod](#example-mod)
- [The base game as a mod](#the-base-game-as-a-mod)
- [Implementation plan](#implementation-plan) - [Implementation plan](#implementation-plan)
- [Open questions](#open-questions) - [Open questions](#open-questions)
@@ -64,6 +66,21 @@ result of breaking and placing immediately, and the server sends a correction if
Moving from the current setup to this is a big change, see the [Implementation plan](#implementation-plan). Right now Moving from the current setup to this is a big change, see the [Implementation plan](#implementation-plan). Right now
the server only relays block changes and doesn't know about blocks, inventories or terrain. the server only relays block changes and doesn't know about blocks, inventories or terrain.
## Creating a mod
```sh
deno task new-mod copper_tools "Copper Tools" # copies templates/mod to mods/copper_tools
deno task check-mods # validates every mod in mods/
```
The template has one of everything: a block with a custom component, an item, a shaped recipe, textures, and all three
scripts. `check-mods` checks manifests and data files against this spec, checks that every id, item and texture a mod
refers to exists (and that it depends on the mods those come from), and typechecks scripts against the API types in
`common/mod_api/`, which scripts import as `bworld/server`, `bworld/client` and `bworld/worldgen`. Folders in `mods/`
starting with `_` or `.` are ignored.
The game can't load mods yet; see the [Implementation plan](#implementation-plan).
## Mod layout ## Mod layout
``` ```
@@ -119,6 +136,7 @@ Only `manifest.json` is required. Data folders can have subfolders, with one def
| `scripts.server` | no | Server entry. Never sent to clients. | | `scripts.server` | no | Server entry. Never sent to clients. |
| `scripts.client` | no | Client entry, sent to every player. | | `scripts.client` | no | Client entry, sent to every player. |
| `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. | | `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. |
Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by both the 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 server and client entries is copied into both bundles, so **don't import secrets into shared code**. Anything the client
@@ -128,8 +146,11 @@ bundle imports is visible to players.
Everything a mod registers has an id of the form `namespace:name`: Everything a mod registers has an id of the form `namespace:name`:
- `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$`, be at most 32 characters, and not be `bworld`, which is - `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$` and be at most 32 characters.
the base game. - Two namespaces are reserved. `bworld` belongs to the base game mod shipped in `mods/bworld` (see
[The base game as a mod](#the-base-game-as-a-mod)). `engine` belongs to the engine itself, for things every game
needs, like the block-breaking cracks. The one exception is `bworld:air`: it's the engine's empty block, but it keeps
that id because every save already contains it.
- `name` must match `^[a-z0-9_]+$`. - `name` must match `^[a-z0-9_]+$`.
- A mod only registers ids in its own namespace: blocks, items, components, screens, channels and HUD elements. It can - A mod only registers ids in its own namespace: blocks, items, components, screens, channels and HUD elements. It can
_refer to_ any id. _refer to_ any id.
@@ -144,7 +165,9 @@ program**, so the server and each client can number blocks differently. Saves an
`_`, so `textures/ores/tin.png` becomes `<mod_id>:ores_tin`. `_`, so `textures/ores/tin.png` becomes `<mod_id>:ores_tin`.
- Textures are 16×16. The server's build puts every installed mod's textures into one atlas with the base game's, and - Textures are 16×16. The server's build puts every installed mod's textures into one atlas with the base game's, and
clients download that atlas instead of using their own. clients download that atlas instead of using their own.
- A missing texture shows the magenta and black checker and logs a warning. It isn't a load error. - A missing texture shows the magenta and black checker (`engine:missing`) and logs a warning. It isn't a load error.
- The engine's own textures use the `engine` namespace: `engine:missing` and the breaking cracks `engine:break_0` to
`engine:break_8`. They come from `assets/`, not from a mod.
## Blocks ## Blocks
@@ -167,20 +190,22 @@ program**, so the server and each client can number blocks differently. Saves an
} }
``` ```
| Field | Default | Maps to `BlockRegistry` | Meaning | | Field | Default | Maps to `BlockRegistry` | Meaning |
| ---------------------- | ----------- | ----------------------- | --------------------------------------------------------------- | | ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- |
| `id` | required | `id` | The block's id. | | `id` | required | `id` | The block's id. |
| `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. | | `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. |
| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. | | `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. |
| `alpha` | `1` | `alpha` | Opacity for transparent blocks. | | `alpha` | `1` | `alpha` | Opacity for transparent blocks. |
| `collision` | `true` | `has_collision` | Whether entities collide with it. | | `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.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.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`. | | `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 given when broken. |
| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. | | `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. |
| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. | | `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. |
| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. | | `replaceable` | `false` | new | Placing a block into it replaces it, like water. |
| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. |
| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. |
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain
functions. functions.
@@ -235,13 +260,28 @@ clients as part of the container it's in, so client screens can show it.
} }
``` ```
| `type` | Fields | Notes | | `type` | Fields | Notes |
| --------- | --------------------------------------------------------------- | ----------------------------------------------- | | --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as the table in `client/blocks/furnace.ts` | | `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Fits anywhere in the 3×3 grid, like the recipes in `server/game/crafting.ts` |
| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `furnace.ts` | | `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as `FURNACE_RECIPES` in `server/game/blocks.ts` |
| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `server/game/blocks.ts` |
Two recipes with the same `input` are a load error. Server scripts can also register recipe types of their own through A shaped recipe, for the crafting grid in the player's inventory screen:
`ctx.recipes`. Crafting recipes are reserved until the game has crafting.
```json
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": ["PPP", "P P", "PPP"],
"key": { "P": "bworld:planks" },
"result": { "id": "bworld:chest", "count": 1 }
}
}
```
Two furnace recipes with the same `input`, or two shaped recipes with the same pattern and key, are a load error. Server
scripts can also register recipe types of their own through `ctx.recipes`.
## Server scripts ## Server scripts
@@ -353,6 +393,7 @@ interface Player {
readonly position: { x: number; y: number; z: number }; readonly position: { x: number; y: number; z: number };
readonly inventory: Container; // 36 slots, hotbar is 0-8 readonly inventory: Container; // 36 slots, hotbar is 0-8
readonly selected_slot: number; readonly selected_slot: number;
readonly held_item: ItemStack | undefined;
give_item(id: string, count?: number, data?: unknown): void; give_item(id: string, count?: number, data?: unknown): void;
send_message(text: string): void; send_message(text: string): void;
teleport(x: number, y: number, z: number): void; teleport(x: number, y: number, z: number): void;
@@ -656,18 +697,75 @@ ctx.net.send("copper_tools:reset_stats", {});
Chunks are generated from the world seed by chunk workers on **both** the server and every client, and all of them must Chunks are generated from the world seed by chunk workers on **both** the server and every client, and all of them must
produce identical terrain. So worldgen scripts must be **deterministic**. produce identical terrain. So worldgen scripts must be **deterministic**.
`worldgen/ores.json` uses the same fields as the `ORES` table in `client/generation.ts`: Each chunk is generated in three passes:
1. **Terrain.** One terrain generator fills in the ground, water and trees.
2. **Ores.** Every mod's `worldgen/ores.json`, in load order.
3. **Features.** Every mod's registered features, in load order.
### Terrain generators
A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`.
Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN`
setting when there are several. A server with no terrain generator refuses to start.
```ts
export function setup(gen: WorldgenContext) {
gen.register_terrain("bworld:overworld", (chunk) => {
const height_noise = chunk.noise_2d("height");
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
const wx = chunk.x * 16 + x;
const wz = chunk.z * 16 + z;
const height = Math.floor((height_noise(wx * 0.01, wz * 0.01) + 1) * 15 + 50);
for (let y = 0; y <= height; y++) {
chunk.set_block(wx, y, wz, y === height ? "bworld:grass" : "bworld:stone");
}
chunk.set_height(wx, wz, height);
chunk.set_biome(wx, wz, "bworld:plains");
}
}
});
}
```
A terrain generator gets a `TerrainChunk`: a `FeatureChunk` (below) plus `set_height(x, z, height)` and
`set_biome(x, z, biome)`, which feed `height_at` / `biome_at` in later passes. Its `set_block` can write up to one chunk
away, like trees. Writes into another chunk only fill air, so the result doesn't depend on which chunk generates first.
Seeding is exact, so a generator ported from the current code produces the same terrain:
- `noise_2d(name)` is `create_noise_2d(new Alea(seed + "_" + name))` from `@paulaboks/rng`, and `noise_3d` is the same
with `create_noise_3d`. Both are cached per seed and name.
- A terrain generator's `rng` is `new Alea(seed + "_chunk_" + x + "_" + z)`.
- A feature's `rng` is `new Alea(seed + "_feature_" + feature_id + "_" + x + "_" + z)`.
### Ores
`worldgen/ores.json` uses the same fields as the `ORES` table in `common/generation.ts`, plus the block they replace:
```json ```json
{ {
"format_version": 1, "format_version": 1,
"ores": [ "ores": [
{ "id": "copper_tools:rich_copper_ore", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 } {
"id": "copper_tools:rich_copper_ore",
"replaces": "bworld:stone",
"min_y": 5,
"max_y": 40,
"scale": 0.05,
"threshold": 0.72
}
] ]
} }
``` ```
`scripts.worldgen` registers features, which run on each chunk after the base terrain and trees: Each block in the chunk that is `replaces` and between `min_y` and `max_y` becomes the first ore whose `noise_3d(id)` at
`(x, y, z) * scale` is above `threshold`.
### Features
`scripts.worldgen` registers features, which run on each chunk after terrain and ores:
```ts ```ts
import type { WorldgenContext } from "bworld/worldgen"; import type { WorldgenContext } from "bworld/worldgen";
@@ -882,6 +980,130 @@ class SmelterStats implements ModScreen {
} }
``` ```
## The base game as a mod
Everything that makes bworld _bworld_ (its blocks, items, recipes, textures, block behavior and terrain) moves into a
mod at `mods/bworld`, written against the same API as any other mod. The engine keeps only what every game built on it
needs.
This is how the mod API gets tested for real: if the base game can't be written as a mod, the API is missing something,
and other mods would hit the same wall. It also means there's one way content works instead of two, so mods can do
anything the base game does.
### What moves
| Content | Now | In `mods/bworld` |
| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- |
| 18 blocks | `common/blocks/*.ts` | `blocks/*.json` |
| 11 items | `common/items/*.ts` | `items/*.json` |
| 62 textures | `assets/sprites/textures/` | `textures/` |
| 5 crafting recipes | `server/game/crafting.ts` | `recipes/*.json`, type `shaped` |
| 6 furnace recipes, 2 fuels | `server/game/blocks.ts` | `recipes/*.json`, types `furnace` and `fuel` |
| Hoeing grass and dirt | `server/game/blocks.ts` | component `bworld:hoeable`, params `{ tool, into }` |
| 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) |
| Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` |
Not moved:
- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt
as a component once server scripts exist, as a good first test of tile data plus ticking.
- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves
once tooltips exist.
### What stays in the engine
Rendering, physics and player controls; chunks, meshing and the generation passes (noise helpers, chunk assembly, ores,
features); networking, saving and the game server; registries and the mod loader; inventories, the crafting grid, slot
click rules and the generic container screen; chat and `/give`; `bworld:air`; and the engine assets in `assets/`: the UI
sprites, font, player sprite and the `engine:` textures (the breaking cracks and the missing texture).
### Rules that keep existing worlds working
Worlds saved before the move must load afterwards with nothing changed:
1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. The
JSON uses exactly the ids the TypeScript files register now.
2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If
the generator's output changes at all, every existing world silently changes with it: trees move and blocks players
broke reappear. The golden terrain test (phase 0) enforces this.
3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main`
plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved
by the current code load into them.
4. **The save format and protocol don't change.** Clients receive `mods/bworld` like any other mod.
### Phases
Each phase ends with every test passing and the game playable.
**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which so far
checks `mods/bworld` against the game and the mod tools.
- Move the test scripts used while building steps 1–3 into the repo as `deno test` files under `tests/`: server logic
(breaking, placing, crafting, chest, furnace, saves), client and server terrain agreement, click prediction, and the
end-to-end WebSocket test.
- Record golden fixtures from the current code: `tests/fixtures/registry.json`, every block and item with all its fields
(functions left out), and `tests/fixtures/terrain.json`, a SHA-256 of the final blocks of 64 chunks for 3 seeds,
including negative coordinates and chunks with trees on their borders.
- Save a world from the current code with chests, a running furnace and some player inventories as
`tests/fixtures/world_v2.json`, with a test that loads it and checks everything is where it was.
**Phase 1: data** (after steps 4 and 5).
- Create `mods/bworld` with its manifest and credits, and move the 62 textures there. Rename the breaking cracks to
`engine:break_0`–`8` and the fallback to `engine:missing`.
- Generate the block, item and recipe JSON with a script that reads the current registries, instead of writing it by
hand. Hand-copying 18 blocks' fields is how typos get in. Done: `deno task export-bworld` writes them, and
`tests/bworld_mod_test.ts` fails if they drift from the game.
- The loader registers the recipes and `server/game/crafting.ts` matches against them. Behaviors stay in
`server/game/blocks.ts`, still keyed by block id, for now.
- Delete `common/blocks/` and `common/items/`.
- Check: the registry built from JSON equals `registry.json`, and all tests pass.
**Phase 2: behavior** (after step 6, and container screens from step 7).
- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and
`bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, whose layouts are the
`ScreenLayout`s the server sends now.
- The block JSON lists the components. Delete `server/game/blocks.ts`.
- Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and
`world_v2.json` still pass.
**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.
- 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.
- `/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 server refuses to start without a terrain generator, naming the mods that could provide one.
### Done when
- Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`.
- Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing
somewhere.
- All tests pass, the golden fixtures are unchanged, and a world saved before the move plays exactly the same after it.
## Implementation plan ## Implementation plan
Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working: Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working:
@@ -911,6 +1133,9 @@ Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps
Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4 Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4
plus data in step 5) before scripts exist. plus data in step 5) before scripts exist.
Moving the base game into `mods/bworld` happens alongside steps 4–9. See
[The base game as a mod](#the-base-game-as-a-mod) for which phase follows which step.
## Open questions ## Open questions
- **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to - **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to
+1
View File
@@ -6,4 +6,5 @@ EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
has_collision: false, has_collision: false,
transparent: true, transparent: true,
alpha: 0.8, alpha: 0.8,
replaceable: true,
}); });
+8
View File
@@ -48,6 +48,12 @@ export class EverythingRegistry {
static get_registry<T>(registry: string): T[] { static get_registry<T>(registry: string): T[] {
return this.#id_to_value.get(registry) as T[]; return this.#id_to_value.get(registry) as T[];
} }
// [key, value] pairs in registration order
static entries<T>(registry: string): [string, T][] {
const values = this.#id_to_value.get(registry) ?? [];
return [...(this.#key_to_id.get(registry) ?? [])].map(([key, id]) => [key, values[id] as T]);
}
} }
interface TextureSideTopBottom { interface TextureSideTopBottom {
@@ -96,6 +102,8 @@ export interface BlockRegistry {
// right clicking it does something instead of placing a block, clients don't predict placing against it. // right clicking it does something instead of placing a block, clients don't predict placing against it.
// behavior runs on the server, see server/game/blocks.ts // behavior runs on the server, see server/game/blocks.ts
interactive?: boolean; interactive?: boolean;
// placing a block into it replaces it, like water
replaceable?: boolean;
compiled_states?: CompiledStateDefinition[]; compiled_states?: CompiledStateDefinition[];
} }
+64
View File
@@ -0,0 +1,64 @@
// what client scripts get, see "Client scripts" and "GUIs" in MODS.md
import type { Id, KeyCode, ModInfo, Position } from "./shared.ts";
export type { Id, ItemStack, KeyCode, ModInfo, Position } from "./shared.ts";
export interface ClientContext {
mod: ModInfo;
ui: ClientUi;
hud: HudRegistry;
input: { bind(id: Id, default_key: KeyCode, on_press: () => void): void };
net: ClientNet;
player: { readonly name: string; readonly position: Readonly<Position> };
// read only, what this client sees
world: { get_block(x: number, y: number, z: number): Id | undefined };
log(...args: unknown[]): void;
}
export interface ModScreen<Props = unknown> {
on_open?(): void;
on_tick?(dt: number): void;
on_render(g: Graphics): void;
// the server sent new props for this screen
on_props?(props: Props): void;
on_close?(): void;
// return true to keep the screen open when escape is pressed
on_escape?(): boolean;
}
export type Color = [number, number, number, number];
// immediate mode drawing, like the debug ui, styled with assets/sprites/ui.png
export interface Graphics {
readonly width: number;
readonly height: number;
readonly mouse: { x: number; y: number; down: boolean; pressed: boolean };
rect(x: number, y: number, w: number, h: number, color?: Color): void;
panel(x: number, y: number, w: number, h: number): void;
text(text: string, x: number, y: number, options?: { scale?: number; color?: Color }): void;
measure_text(text: string, scale?: number): number;
texture(id: Id, x: number, y: number, w: number, h: number): void;
item(id: Id, x: number, y: number, count?: number): void;
clip(x: number, y: number, w: number, h: number, draw: () => void): void;
button(label: string, x: number, y: number, w: number, h: number): boolean;
text_input(id: string, x: number, y: number, w: number): string;
slider(id: string, x: number, y: number, w: number, min: number, max: number): number;
// server synced slots, same rules as container screens
slots(container: string, layout: { slot: number; x: number; y: number }[], x: number, y: number): void;
key_pressed(key: KeyCode): boolean;
}
export interface ClientUi {
register_screen<Props>(id: Id, create: (props: Props) => ModScreen<Props>): void;
// open a screen that doesn't involve the server, like a settings page
open<Props>(id: Id, props: Props): void;
}
export interface HudRegistry {
register(id: Id, element: { on_render(g: Graphics): void }): void;
}
export interface ClientNet {
on<T = unknown>(channel: Id, handler: (data: T) => void): void;
send(channel: Id, data: unknown): void;
}
+252
View File
@@ -0,0 +1,252 @@
// what server scripts get, see "Server scripts" in MODS.md
import type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts";
export type { EventSignal, Face, Id, ItemStack, ModInfo, Position } from "./shared.ts";
export interface ServerContext {
mod: ModInfo;
components: ComponentRegistry; // only during setup
commands: CommandRegistry; // only during setup
events: { before: ServerBeforeEvents; after: ServerAfterEvents };
system: System;
world: ServerWorld;
players: PlayerList;
containers: ContainerApi;
recipes: RecipeApi;
ui: ServerUi;
net: ServerNet;
storage: ModStorage;
log(...args: unknown[]): void;
}
// components
export interface BlockRef {
readonly id: Id;
readonly x: number;
readonly y: number;
readonly z: number;
// tile data, any json value. saved with the world, never sent to clients
// deno-lint-ignore no-explicit-any
data: any;
}
// P is the component's params from the block json
// deno-lint-ignore no-explicit-any
export interface BlockComponent<P = any> {
on_create?(block: BlockRef, params: P): void;
on_break?(block: BlockRef, params: P, player: Player | undefined): void;
on_click?(block: BlockRef, params: P, player: Player): void;
// return true if it did something, so no block gets placed
on_interact?(block: BlockRef, params: P, player: Player): boolean;
on_tick?(block: BlockRef, params: P, dt: number): void;
on_second?(block: BlockRef, params: P, dt: number): void;
}
// deno-lint-ignore no-explicit-any
export interface ItemComponent<P = any> {
on_create?(item: ItemStack, params: P): void;
get_lore?(item: ItemStack, params: P): string;
on_use?(item: ItemStack, params: P, player: Player): void;
}
export interface ComponentRegistry {
register_block<P>(id: Id, component: BlockComponent<P>): void;
register_item<P>(id: Id, component: ItemComponent<P>): void;
}
// commands
export interface Command {
description: string;
usage: string;
run(args: string[], player: Player): void;
}
export interface CommandRegistry {
register(name: string, command: Command): void;
}
// events
export interface Cancelable {
cancel: boolean;
}
export interface BlockBreakEvent {
readonly player: Player;
readonly block: BlockRef;
readonly item: ItemStack | undefined;
}
export interface BlockPlaceEvent {
readonly player: Player;
readonly block: { readonly id: Id } & Position;
readonly face: Face;
readonly item: ItemStack | undefined;
}
export interface BlockInteractEvent {
readonly player: Player;
readonly block: BlockRef;
readonly item: ItemStack | undefined;
}
export interface ChatSendEvent {
readonly player: Player;
message: string;
}
export interface ServerBeforeEvents {
block_break: EventSignal<BlockBreakEvent & Cancelable>;
block_place: EventSignal<BlockPlaceEvent & Cancelable>;
block_interact: EventSignal<BlockInteractEvent & Cancelable>;
chat_send: EventSignal<ChatSendEvent & Cancelable>;
}
export interface ServerAfterEvents {
block_break: EventSignal<BlockBreakEvent>;
block_place: EventSignal<BlockPlaceEvent>;
block_interact: EventSignal<BlockInteractEvent>;
chat_send: EventSignal<Readonly<ChatSendEvent>>;
player_join: EventSignal<{ readonly player: Player }>;
player_leave: EventSignal<{ readonly player: Player }>;
server_start: EventSignal<Record<never, never>>;
tick: EventSignal<{ readonly dt: number }>;
}
// world and players
export interface ServerWorld {
get_block(x: number, y: number, z: number): Id | undefined; // undefined when the chunk isn't loaded
set_block(x: number, y: number, z: number, id: Id): boolean; // runs on_break / on_create, synced to everyone
get_state(x: number, y: number, z: number, name: string): number | undefined;
set_state(x: number, y: number, z: number, name: string, value: number): boolean;
get_block_data<T>(x: number, y: number, z: number): T | undefined;
is_loaded(x: number, z: number): boolean;
readonly seed: string;
}
export interface Player {
readonly id: string;
readonly name: string;
readonly position: Readonly<Position>;
readonly inventory: Container; // 36 slots, hotbar is 0-8
readonly selected_slot: number;
readonly held_item: ItemStack | undefined;
give_item(id: Id, count?: number, data?: unknown): void;
send_message(text: string): void;
teleport(x: number, y: number, z: number): void;
}
export interface PlayerList {
all(): Player[];
get(id: string): Player | undefined;
by_name(name: string): Player | undefined;
}
export interface System {
run_timeout(fn: () => void, ticks: number): number;
run_interval(fn: () => void, ticks: number): number;
clear_run(handle: number): void;
readonly current_tick: number;
}
// small key value store per mod, saved with the world
export interface ModStorage {
get<T>(key: string): T | undefined;
set(key: string, value: unknown): void; // json values only
delete(key: string): void;
}
// containers and recipes
export interface Container {
readonly id: string;
readonly size: number;
get(slot: number): ItemStack | undefined;
set(slot: number, item: ItemStack | undefined): void;
add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
on_change(fn: (slot: number) => void): () => void;
}
export interface ContainerApi {
create(size: number): Container; // saved with the world
get(id: string): Container | undefined;
delete(id: string): void;
}
export interface RecipeApi {
furnace_result(input: Id): { output: ItemStack; cook_time: number } | undefined;
fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel
is_fuel(item: Id): boolean;
is_smeltable(item: Id): boolean;
}
// guis
export interface ActionForm {
title: string;
body?: string;
buttons: { text: string; icon?: Id }[];
}
export interface MessageForm {
title: string;
body: string;
buttons: [string, string];
}
export type FormField =
| { type: "toggle"; label: string; default?: boolean }
| { type: "slider"; label: string; min: number; max: number; step?: number; default?: number }
| { type: "dropdown"; label: string; options: string[]; default?: number }
| { type: "text"; label: string; placeholder?: string; default?: string; max_length?: number };
export interface ModalForm {
title: string;
fields: FormField[];
}
export type FormResult<T> = ({ canceled: true } & Partial<T>) | ({ canceled: false } & T);
export type SlotFilter = "smeltable" | "fuel" | ((item: ItemStack) => boolean);
export interface ContainerScreenOptions {
title: string;
container: Container;
// x and y in slot units
layout: { slot: number; x: number; y: number; filter?: SlotFilter; output_only?: boolean }[];
player_inventory?: boolean;
bars?: { id: string; x: number; y: number; texture: Id }[];
labels?: { x: number; y: number; property: string }[];
}
export interface ScreenHandle<Props = unknown> {
readonly player: Player;
set_property(id: string, value: number): void;
update(props: Props): void; // custom screens only
close(): void;
on_close(fn: () => void): void;
}
export interface ServerUi {
message_form(player: Player, form: MessageForm): Promise<FormResult<{ selection: 0 | 1 }>>;
action_form(player: Player, form: ActionForm): Promise<FormResult<{ selection: number }>>;
modal_form(player: Player, form: ModalForm): Promise<FormResult<{ values: (boolean | number | string)[] }>>;
open_container(player: Player, options: ContainerScreenOptions): ScreenHandle;
open_screen<Props>(
player: Player,
id: Id,
props: Props,
options?: { containers?: Record<string, Container> },
): ScreenHandle<Props>;
}
// mod channels
export interface ServerNet {
on<T = unknown>(channel: Id, handler: (player: Player, data: T) => void): void;
send(player: Player, channel: Id, data: unknown): void;
broadcast(channel: Id, data: unknown): void;
}
+33
View File
@@ -0,0 +1,33 @@
// types every side of a mod shares. these are the api mods see, not the engine's own classes:
// items are { id, count, data } here, like on the network
export type Id = string;
export interface ItemStack {
readonly id: Id;
count: number;
// any json value, set by server scripts
data?: unknown;
}
export interface ModInfo {
readonly id: string;
readonly version: string;
}
export interface Position {
x: number;
y: number;
z: number;
}
export type Face = "west" | "east" | "bottom" | "top" | "north" | "south";
// KeyboardEvent.code values, like "KeyE" or "Digit1"
export type KeyCode = string;
export type Unsubscribe = () => void;
export interface EventSignal<T> {
subscribe(handler: (event: T) => void): Unsubscribe;
}
+31
View File
@@ -0,0 +1,31 @@
// what worldgen scripts get, see "World generation" in MODS.md. runs in chunk workers and must be deterministic
import type { Id } from "./shared.ts";
export type { Id } from "./shared.ts";
export interface WorldgenContext {
register_terrain(id: Id, generate: (chunk: TerrainChunk) => void): void;
register_feature(id: Id, generate: (chunk: FeatureChunk) => void): void;
}
export interface FeatureChunk {
// chunk coordinates
readonly x: number;
readonly z: number;
readonly seed: string;
// seeded from the seed, chunk and feature id
readonly rng: { next(): number };
// create_noise_2d(new Alea(seed + "_" + name)), cached per seed and name
noise_2d(name: string): (x: number, z: number) => number;
noise_3d(name: string): (x: number, y: number, z: number) => number;
// surface height and biome, inside this chunk only
height_at(x: number, z: number): number;
biome_at(x: number, z: number): Id;
get_block(x: number, y: number, z: number): Id | undefined; // inside this chunk only
set_block(x: number, y: number, z: number, id: Id): void; // up to one chunk away, like trees
}
export interface TerrainChunk extends FeatureChunk {
set_height(x: number, z: number, height: number): void;
set_biome(x: number, z: number, biome: Id): void;
}
+321
View File
@@ -0,0 +1,321 @@
// 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, tools/export_bworld_mod.ts the other one
import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts";
export const FORMAT_VERSION = 1;
export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
export const NAMESPACE_PATTERN = /^[a-z0-9_]{1,32}$/;
export const RESERVED_NAMESPACES = ["bworld", "engine"];
type BlockTextures = BlockRegistry["textures"];
export interface ManifestJson {
format_version: number;
id: string;
name: string;
description?: string;
version: string;
authors?: string[];
game_version?: string;
dependencies?: { id: string; version: string }[];
scripts?: { server?: string; client?: string; worldgen?: string };
credits?: string;
}
export interface BlockJson {
id: string;
textures: BlockTextures;
transparent?: boolean;
alpha?: number;
collision?: boolean;
mining?: { toughness: number; tool?: string; requires_tool?: boolean };
drops?: string;
item?: boolean;
interactive?: boolean;
replaceable?: boolean;
states?: BlockStateDefinition[];
components?: Record<string, unknown>;
}
export interface ItemJson {
id: string;
texture: string;
tool?: string;
places?: string;
max_stack?: number;
lore?: string;
components?: Record<string, unknown>;
}
export type RecipeJson =
| {
type: "shaped";
pattern: string[];
key: Record<string, string>;
result: { id: string; count: number };
}
| { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number }
| { type: "fuel"; item: string; burn_time: number };
// the crafting grid's format, see server/game/crafting.ts
export interface GridRecipe {
width: number;
height: number;
pattern: (string | undefined)[];
result: { id: string; count: number };
}
// blocks
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.alpha !== undefined) json.alpha = block.alpha;
if (!block.has_collision) json.collision = false;
if (block.toughness !== undefined) {
json.mining = { toughness: block.toughness };
if (block.tool_to_break !== undefined) json.mining.tool = block.tool_to_break;
if (block.requires_tool) json.mining.requires_tool = true;
}
if (block.drop_table !== undefined) json.drops = block.drop_table;
if (!has_item) json.item = false;
if (block.interactive) json.interactive = true;
if (block.replaceable) json.replaceable = true;
if (block.states) json.states = block.states;
return json;
}
export function block_from_json(json: BlockJson): { block: BlockRegistry; has_item: boolean } {
const block: BlockRegistry = {
id: json.id,
textures: json.textures,
has_collision: json.collision ?? true,
};
if (json.transparent) block.transparent = true;
if (json.alpha !== undefined) block.alpha = json.alpha;
if (json.mining) {
block.toughness = json.mining.toughness;
block.requires_tool = json.mining.requires_tool ?? false;
if (json.mining.tool !== undefined) block.tool_to_break = json.mining.tool;
}
if (json.drops !== undefined) block.drop_table = json.drops;
if (json.interactive) block.interactive = true;
if (json.replaceable) block.replaceable = true;
if (json.states) block.states = json.states;
return { block, has_item: json.item ?? true };
}
// items that aren't the item form of a block
export function item_to_json(id: string, item: ItemRegistry): ItemJson {
if (typeof item.texture_id !== "string") {
throw new Error(`${id} picks its texture with a function, which json can't hold`);
}
const json: ItemJson = { id, texture: item.texture_id };
if (item.tool_type !== undefined) json.tool = item.tool_type;
if (item.block_id !== undefined) json.places = item.block_id;
return json;
}
export function item_from_json(json: ItemJson): ItemRegistry {
const item: ItemRegistry = { texture_id: json.texture };
if (json.tool !== undefined) item.tool_type = json.tool;
if (json.places !== undefined) item.block_id = json.places;
return item;
}
// shaped recipes <-> the grid format
export function grid_recipe_to_json(recipe: GridRecipe): RecipeJson {
const key: Record<string, string> = {};
const letters = new Map<string, string>();
for (const id of recipe.pattern) {
if (id === undefined || letters.has(id)) continue;
const letter = pick_letter(id, new Set(letters.values()));
letters.set(id, letter);
key[letter] = id;
}
const pattern: string[] = [];
for (let y = 0; y < recipe.height; y++) {
let row = "";
for (let x = 0; x < recipe.width; x++) {
const id = recipe.pattern[y * recipe.width + x];
row += id === undefined ? " " : letters.get(id);
}
pattern.push(row);
}
return { type: "shaped", pattern, key, result: recipe.result };
}
export function grid_recipe_from_json(json: Extract<RecipeJson, { type: "shaped" }>): GridRecipe {
const height = json.pattern.length;
const width = Math.max(...json.pattern.map((row) => row.length));
const pattern: (string | undefined)[] = [];
for (const row of json.pattern) {
for (let x = 0; x < width; x++) {
const letter = row[x] ?? " ";
pattern.push(letter === " " ? undefined : json.key[letter]);
}
}
return { width, height, pattern, result: json.result };
}
// a readable letter for an item in a pattern: its first letter if free, like P for planks
function pick_letter(id: string, taken: Set<string>) {
const name = id.split(":")[1].toUpperCase();
for (const letter of [...name, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]) {
if (/[A-Z]/.test(letter) && !taken.has(letter)) {
return letter;
}
}
throw new Error(`Ran out of letters for ${id}`);
}
// validation. every function returns a list of problems, empty when it's fine
type Problems = string[];
export function validate_manifest(json: unknown, folder_name: string): Problems {
const problems: Problems = [];
if (!is_object(json)) return ["manifest.json must be an object"];
if (json.format_version !== FORMAT_VERSION) {
problems.push(`format_version must be ${FORMAT_VERSION}`);
}
if (typeof json.id !== "string" || !NAMESPACE_PATTERN.test(json.id)) {
problems.push("id must be 1-32 characters of a-z, 0-9 and _");
} else if (json.id !== folder_name) {
problems.push(`id "${json.id}" must match the folder name "${folder_name}"`);
}
if (typeof json.name !== "string" || json.name.length === 0) problems.push("name is required");
if (typeof json.version !== "string" || !/^\d+\.\d+\.\d+/.test(json.version)) {
problems.push("version must be semver, like 1.0.0");
}
if (json.dependencies !== undefined) {
if (!Array.isArray(json.dependencies)) {
problems.push("dependencies must be a list");
} else {
for (const dep of json.dependencies) {
if (!is_object(dep) || typeof dep.id !== "string" || typeof dep.version !== "string") {
problems.push("each dependency needs an id and a version range");
}
}
}
}
if (json.scripts !== undefined) {
if (!is_object(json.scripts)) {
problems.push("scripts must be an object");
} else {
for (const [side, path] of Object.entries(json.scripts)) {
if (!["server", "client", "worldgen"].includes(side)) problems.push(`unknown script "${side}"`);
if (typeof path !== "string") problems.push(`scripts.${side} must be a path`);
}
}
}
return problems;
}
export function validate_block(json: unknown): Problems {
if (!is_object(json)) return ["block must be an object"];
const problems = validate_id(json.id, "id");
const textures = json.textures;
const texture_ok = typeof textures === "string" ||
(is_object(textures) &&
(["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[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)) {
problems.push("alpha must be between 0 and 1");
}
if (json.mining !== undefined) {
if (!is_object(json.mining) || typeof json.mining.toughness !== "number" || json.mining.toughness < 0) {
problems.push("mining.toughness must be a number of seconds");
}
}
if (json.drops !== undefined) problems.push(...validate_id(json.drops, "drops"));
if (json.states !== undefined) {
const states = json.states;
if (!Array.isArray(states)) {
problems.push("states must be a list");
} else {
const bits = states.reduce((sum, s) => sum + (is_object(s) && typeof s.bits === "number" ? s.bits : 0), 0);
if (bits > 16) problems.push(`states use ${bits} bits, the most is 16`);
}
}
return problems;
}
export function validate_item(json: unknown): Problems {
if (!is_object(json)) return ["item must be an object"];
const problems = validate_id(json.id, "id");
problems.push(...validate_id(json.texture, "texture"));
if (json.places !== undefined) problems.push(...validate_id(json.places, "places"));
if (json.max_stack !== undefined && (!Number.isInteger(json.max_stack) || (json.max_stack as number) < 1)) {
problems.push("max_stack must be a positive whole number");
}
return problems;
}
export function validate_recipe(json: unknown): Problems {
if (!is_object(json)) return ["recipe must be an object"];
const problems: Problems = [];
switch (json.type) {
case "shaped": {
const pattern = json.pattern;
if (
!Array.isArray(pattern) || pattern.length < 1 || pattern.length > 3 ||
!pattern.every((row) => typeof row === "string" && row.length >= 1 && row.length <= 3)
) {
problems.push("pattern must be 1-3 rows of 1-3 characters");
} else if (is_object(json.key)) {
for (const letter of pattern.join("").replaceAll(" ", "")) {
if (!(letter in json.key)) problems.push(`pattern uses "${letter}" but key doesn't define it`);
}
}
if (!is_object(json.key)) {
problems.push("key must map letters to item ids");
} else {
for (const id of Object.values(json.key)) problems.push(...validate_id(id, "key"));
}
problems.push(...validate_stack(json.result, "result"));
break;
}
case "furnace":
problems.push(...validate_id(json.input, "input"), ...validate_stack(json.output, "output"));
if (!Number.isInteger(json.cook_time) || (json.cook_time as number) < 1) {
problems.push("cook_time must be a positive number of ticks");
}
break;
case "fuel":
problems.push(...validate_id(json.item, "item"));
if (!Number.isInteger(json.burn_time) || (json.burn_time as number) < 1) {
problems.push("burn_time must be a positive number of ticks");
}
break;
default:
problems.push('type must be "shaped", "furnace" or "fuel"');
}
return problems;
}
function validate_id(value: unknown, field: string): Problems {
return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`];
}
function validate_stack(value: unknown, field: string): Problems {
if (!is_object(value)) return [`${field} must be { id, count }`];
const problems = validate_id(value.id, `${field}.id`);
if (!Number.isInteger(value.count) || (value.count as number) < 1) {
problems.push(`${field}.count must be a positive whole number`);
}
return problems;
}
function is_object(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+9 -1
View File
@@ -2,7 +2,11 @@
"tasks": { "tasks": {
"build": "deno run -A build.ts", "build": "deno run -A build.ts",
"serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build", "serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build",
"server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts" "server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts",
"new-mod": "deno run --allow-read --allow-write tools/new_mod.ts",
"check-mods": "deno run --allow-read --allow-run tools/check_mods.ts",
"export-bworld": "deno run --allow-read --allow-write --allow-run tools/export_bworld_mod.ts",
"test": "deno test --allow-read --allow-write --allow-run tests/"
}, },
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"] "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
@@ -16,8 +20,12 @@
}, },
"imports": { "imports": {
"$/": "./", "$/": "./",
"bworld/server": "./common/mod_api/server.ts",
"bworld/client": "./common/mod_api/client.ts",
"bworld/worldgen": "./common/mod_api/worldgen.ts",
"@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2",
"@paulaboks/rng": "jsr:@paulaboks/rng@^0.0.3", "@paulaboks/rng": "jsr:@paulaboks/rng@^0.0.3",
"@std/assert": "jsr:@std/assert@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.23", "@std/fs": "jsr:@std/fs@^1.0.23",
"@std/http": "jsr:@std/http@^1.0.23", "@std/http": "jsr:@std/http@^1.0.23",
"gl-matrix": "npm:gl-matrix@^3.4.4", "gl-matrix": "npm:gl-matrix@^3.4.4",
Generated
+11 -2
View File
@@ -3,6 +3,7 @@
"specifiers": { "specifiers": {
"jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2", "jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2",
"jsr:@paulaboks/rng@^0.0.3": "0.0.3", "jsr:@paulaboks/rng@^0.0.3": "0.0.3",
"jsr:@std/assert@1": "1.0.14",
"jsr:@std/cli@^1.0.27": "1.0.27", "jsr:@std/cli@^1.0.27": "1.0.27",
"jsr:@std/encoding@1.0.5": "1.0.5", "jsr:@std/encoding@1.0.5": "1.0.5",
"jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10",
@@ -12,6 +13,7 @@
"jsr:@std/html@^1.0.5": "1.0.5", "jsr:@std/html@^1.0.5": "1.0.5",
"jsr:@std/http@*": "1.0.24", "jsr:@std/http@*": "1.0.24",
"jsr:@std/http@^1.0.23": "1.0.24", "jsr:@std/http@^1.0.23": "1.0.24",
"jsr:@std/internal@^1.0.10": "1.0.12",
"jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6", "jsr:@std/net@^1.0.6": "1.0.6",
@@ -30,6 +32,12 @@
"@paulaboks/rng@0.0.3": { "@paulaboks/rng@0.0.3": {
"integrity": "8d2571f9f406dab2674178f4034825cc654c5a52aa7c2a176a25f8b713eee202" "integrity": "8d2571f9f406dab2674178f4034825cc654c5a52aa7c2a176a25f8b713eee202"
}, },
"@std/assert@1.0.14": {
"integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4",
"dependencies": [
"jsr:@std/internal@^1.0.10"
]
},
"@std/cli@1.0.27": { "@std/cli@1.0.27": {
"integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de" "integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de"
}, },
@@ -45,7 +53,7 @@
"@std/fs@1.0.23": { "@std/fs@1.0.23": {
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
"dependencies": [ "dependencies": [
"jsr:@std/internal", "jsr:@std/internal@^1.0.12",
"jsr:@std/path" "jsr:@std/path"
] ]
}, },
@@ -78,7 +86,7 @@
"@std/path@1.1.4": { "@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [ "dependencies": [
"jsr:@std/internal" "jsr:@std/internal@^1.0.12"
] ]
}, },
"@std/streams@1.1.2": { "@std/streams@1.1.2": {
@@ -98,6 +106,7 @@
"dependencies": [ "dependencies": [
"jsr:@gfx/canvas-wasm@~0.4.2", "jsr:@gfx/canvas-wasm@~0.4.2",
"jsr:@paulaboks/rng@^0.0.3", "jsr:@paulaboks/rng@^0.0.3",
"jsr:@std/assert@1",
"jsr:@std/fs@^1.0.23", "jsr:@std/fs@^1.0.23",
"jsr:@std/http@^1.0.23", "jsr:@std/http@^1.0.23",
"npm:gl-matrix@^3.4.4", "npm:gl-matrix@^3.4.4",
+82
View File
@@ -0,0 +1,82 @@
# bworld
Textures and sprites by Kenney, from these CC0 packs:
# Tiny town
https://kenney.nl/assets/tiny-town
license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/)
# Roguelike
https://kenney.nl/assets/roguelike-rpg-pack
license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/)
# UI
https://kenney.nl/assets/ui-pack-pixel-adventure
license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/)
# m6x11.ttf
Font by Daniel Linssen (https://managore.itch.io)
https://managore.itch.io/m6x11 (https://web.archive.org/web/20260217032856/https://managore.itch.io/m6x11)
license: `free to use with attribution`
# Tiny wonder farm
https://butterymilk.itch.io/tiny-wonder-farm-asset-pack
(https://web.archive.org/web/20260204234306/https://butterymilk.itch.io/tiny-wonder-farm-asset-pack)
license:
```
There's two versions, free and paid (premium):
Free: It includes a limited number of items available for free.
You are free to use sprites included in the pack in any of your non-commercial projects as well as edit the sprites and
add something new!
Premium: It includes everything shown from the showcase, all tilemaps, sprites, characters and more!
You are free to use sprites included in the pack in any of your commercial or non-commercial projects as well as edit
the sprites and add something new!
You can't resell the sprites, even if changes were made. You cannot use it in any projects related to NFT."
```
# Farmer's delight
https://github.com/vectorwing/FarmersDelight
License:
```
MIT License
Copyright (c) 2020 vectorwing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
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.
```
+21
View File
@@ -0,0 +1,21 @@
# bworld
The base game as a mod, following "The base game as a mod" in `MODS.md`.
**The game doesn't load this yet.** Until the mod loader exists, the game still registers everything from
`common/blocks/`, `common/items/`, `server/game/crafting.ts` and `server/game/blocks.ts`. The `blocks/`, `items/` and
`recipes/` folders here are generated from those by:
```sh
deno task export-bworld
```
Run it after changing any of them. `tests/bworld_mod_test.ts` fails when this folder and the game disagree.
Still to move here, by phase:
| Phase | What |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Textures from `assets/sprites/textures/` (the build still reads them from there). |
| 2 | Block behavior from `server/game/blocks.ts`: `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, and the watering can's `bworld:watering_can` item component. |
| 3 | Terrain, biomes, trees and ores from `common/generation.ts`. |
+14
View File
@@ -0,0 +1,14 @@
{
"format_version": 1,
"block": {
"id": "bworld:chest",
"textures": "bworld:planks",
"collision": false,
"mining": {
"toughness": 8,
"tool": "axe"
},
"drops": "bworld:chest",
"interactive": true
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:coal_ore",
"textures": "bworld:stone_coal",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:coal_ore"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:copper_ore",
"textures": "bworld:stone_copper",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:copper_ore"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:dirt",
"textures": "bworld:dirt",
"collision": false,
"mining": {
"toughness": 2,
"tool": "shovel"
},
"drops": "bworld:dirt"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"format_version": 1,
"block": {
"id": "bworld:furnace",
"textures": {
"front": "bworld:furnace",
"side": "bworld:stone"
},
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:furnace",
"interactive": true
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:glass",
"textures": "bworld:glass",
"transparent": true,
"mining": {
"toughness": 3,
"tool": "pickaxe"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:gold_ore",
"textures": "bworld:stone_gold",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:gold_ore"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"format_version": 1,
"block": {
"id": "bworld:grass",
"textures": {
"top": "bworld:grass_top",
"bottom": "bworld:dirt",
"side": "bworld:grass_side"
},
"collision": false,
"mining": {
"toughness": 2,
"tool": "shovel"
},
"drops": "bworld:dirt",
"item": false
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"format_version": 1,
"block": {
"id": "bworld:hoed_dirt",
"textures": {
"side": "bworld:dirt",
"top": "bworld:hoed_dirt",
"bottom": "bworld:dirt"
},
"collision": false,
"mining": {
"toughness": 5,
"tool": "shovel"
},
"drops": "bworld:dirt",
"states": [
{
"name": "watered",
"bits": 1,
"default": 0
}
]
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:iron_ore",
"textures": "bworld:stone_iron",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:iron_ore"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:leaves",
"textures": "bworld:leaves",
"transparent": true,
"mining": {
"toughness": 3,
"tool": "hoe"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"format_version": 1,
"block": {
"id": "bworld:log",
"textures": {
"side": "bworld:log_side",
"top": "bworld:log_top",
"bottom": "bworld:log_top"
},
"mining": {
"toughness": 3,
"tool": "axe"
},
"drops": "bworld:log"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:planks",
"textures": "bworld:planks",
"mining": {
"toughness": 3,
"tool": "axe"
},
"drops": "bworld:log"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:sand",
"textures": "bworld:sand",
"mining": {
"toughness": 3,
"tool": "shovel"
},
"drops": "bworld:sand"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:snow",
"textures": "bworld:snow",
"mining": {
"toughness": 2,
"tool": "shovel",
"requires_tool": true
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:stone",
"textures": "bworld:stone",
"mining": {
"toughness": 3,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:stone"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "bworld:tin_ore",
"textures": "bworld:stone_tin",
"mining": {
"toughness": 5,
"tool": "pickaxe",
"requires_tool": true
},
"drops": "bworld:tin_ore"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"block": {
"id": "bworld:water",
"textures": "bworld:water",
"transparent": true,
"alpha": 0.8,
"collision": false,
"item": false,
"replaceable": true
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"item": {
"id": "bworld:axe",
"texture": "bworld:axe",
"tool": "axe"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:coal",
"texture": "bworld:coal"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:copper_ingot",
"texture": "bworld:copper_ingot"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:gold_ingot",
"texture": "bworld:gold_ingot"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:hoe",
"texture": "bworld:hoe"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:iron_ingot",
"texture": "bworld:iron_ingot"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:pickaxe",
"texture": "bworld:pickaxe"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:stick",
"texture": "bworld:stick"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:tin_ingot",
"texture": "bworld:tin_ingot"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:watering_can",
"texture": "bworld:watering_can"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"item": {
"id": "bworld:wood_pickaxe",
"texture": "bworld:wood_pickaxe",
"tool": "pickaxe"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"format_version": 1,
"id": "bworld",
"name": "bworld",
"description": "The base game: its blocks, items, recipes and world.",
"version": "0.1.0",
"authors": ["paula"],
"game_version": ">=0.1.0",
"credits": "CREDITS.md"
}
+18
View File
@@ -0,0 +1,18 @@
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": [
"PPP",
"P P",
"PPP"
],
"key": {
"P": "bworld:planks"
},
"result": {
"id": "bworld:chest",
"count": 1
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": [
"SSS",
"S S",
"SSS"
],
"key": {
"S": "bworld:stone"
},
"result": {
"id": "bworld:furnace",
"count": 1
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": [
"L"
],
"key": {
"L": "bworld:log"
},
"result": {
"id": "bworld:planks",
"count": 2
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": [
"P",
"P"
],
"key": {
"P": "bworld:planks"
},
"result": {
"id": "bworld:stick",
"count": 2
}
}
}
@@ -0,0 +1,19 @@
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": [
"PPP",
" S ",
" S "
],
"key": {
"P": "bworld:planks",
"S": "bworld:stick"
},
"result": {
"id": "bworld:wood_pickaxe",
"count": 1
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"recipe": {
"type": "fuel",
"item": "bworld:coal",
"burn_time": 1000
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"recipe": {
"type": "fuel",
"item": "bworld:log",
"burn_time": 100
}
}
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "bworld:coal_ore",
"output": {
"id": "bworld:coal",
"count": 1
},
"cook_time": 100
}
}
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "bworld:copper_ore",
"output": {
"id": "bworld:copper_ingot",
"count": 1
},
"cook_time": 200
}
}
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "bworld:gold_ore",
"output": {
"id": "bworld:gold_ingot",
"count": 1
},
"cook_time": 200
}
}
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "bworld:iron_ore",
"output": {
"id": "bworld:iron_ingot",
"count": 1
},
"cook_time": 200
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "bworld:log",
"output": {
"id": "bworld:coal",
"count": 1
},
"cook_time": 100
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "bworld:tin_ore",
"output": {
"id": "bworld:tin_ingot",
"count": 1
},
"cook_time": 200
}
}
+3 -3
View File
@@ -80,13 +80,13 @@ BLOCK_BEHAVIORS["bworld:chest"] = {
// furnace // furnace
interface FurnaceRecipe { export interface FurnaceRecipe {
input: string; input: string;
output: ItemStack; output: ItemStack;
cook_time: number; cook_time: number;
} }
const FURNACE_RECIPES: FurnaceRecipe[] = [ export const FURNACE_RECIPES: FurnaceRecipe[] = [
{ {
input: "bworld:log", input: "bworld:log",
output: new ItemStack("bworld:coal", 1), output: new ItemStack("bworld:coal", 1),
@@ -119,7 +119,7 @@ const FURNACE_RECIPES: FurnaceRecipe[] = [
}, },
]; ];
const FUEL_VALUES: Record<string, number> = { export const FUEL_VALUES: Record<string, number> = {
"bworld:coal": 1000, "bworld:coal": 1000,
"bworld:log": 100, "bworld:log": 100,
}; };
+2 -2
View File
@@ -8,7 +8,7 @@ export interface CraftingRecipe {
result: { id: string; count: number }; result: { id: string; count: number };
} }
const recipes: CraftingRecipe[] = [ export const CRAFTING_RECIPES: CraftingRecipe[] = [
{ {
width: 3, width: 3,
height: 3, height: 3,
@@ -117,7 +117,7 @@ function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): b
// puts what the grid makes in the result slot // puts what the grid makes in the result slot
export function update_crafting_result(crafting: Container) { export function update_crafting_result(crafting: Container) {
const grid = get_crafting_grid(crafting); const grid = get_crafting_grid(crafting);
const recipe = recipes.find((recipe) => matches_recipe(grid, recipe)); const recipe = CRAFTING_RECIPES.find((recipe) => matches_recipe(grid, recipe));
crafting.set_item(CRAFTING_RESULT_SLOT, recipe ? new ItemStack(recipe.result.id, recipe.result.count) : undefined); crafting.set_item(CRAFTING_RESULT_SLOT, recipe ? new ItemStack(recipe.result.id, recipe.result.count) : undefined);
} }
+3
View File
@@ -0,0 +1,3 @@
# Example Mod
Made by you. List where textures and other assets come from and their licenses here.
+31
View File
@@ -0,0 +1,31 @@
# Example Mod
A bworld mod. The format is described in `MODS.md` at the root of the bworld repo.
## Files
| Path | What it's for |
| --------------------- | ------------------------------------------------------------------------------------ |
| `manifest.json` | The mod's id, name, version, dependencies and scripts. |
| `blocks/*.json` | One block per file. Blocks get an item form with the same id unless `"item": false`. |
| `items/*.json` | Items that aren't a block's item form. |
| `recipes/*.json` | `shaped` crafting, `furnace` smelting and `fuel`. |
| `textures/*.png` | 16×16 textures, named `example_mod:<file name>`. |
| `worldgen/ores.json` | Ores to generate (optional, not included here). |
| `scripts/server.ts` | Game logic: block components, commands, events. Never sent to players. |
| `scripts/client.ts` | Sent to players: custom screens, HUD, keybinds. |
| `scripts/worldgen.ts` | Terrain features. Runs on the server and every client and must be deterministic. |
| `CREDITS.md` | Licenses and thanks, shown on the About page. |
Delete what you don't use, including its entry in `manifest.json`.
## Checking it
From the bworld repo:
```sh
deno task check-mods
```
This validates the manifest and every data file, checks that the ids, textures and items you refer to exist, and
typechecks the scripts.
+13
View File
@@ -0,0 +1,13 @@
{
"format_version": 1,
"block": {
"id": "example_mod:example_block",
"textures": "example_mod:example_block",
"mining": { "toughness": 3, "tool": "pickaxe", "requires_tool": false },
"drops": "example_mod:example_block",
"interactive": true,
"components": {
"example_mod:announce": { "message": "You found the example block!" }
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"item": {
"id": "example_mod:example_item",
"texture": "example_mod:example_item",
"lore": "Four of these make an example block."
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"format_version": 1,
"id": "example_mod",
"name": "Example Mod",
"description": "What this mod adds, in a sentence or two.",
"version": "0.1.0",
"authors": [],
"game_version": ">=0.1.0",
"dependencies": [
{ "id": "bworld", "version": ">=0.1.0" }
],
"scripts": {
"server": "scripts/server.ts",
"client": "scripts/client.ts",
"worldgen": "scripts/worldgen.ts"
},
"credits": "CREDITS.md"
}
+9
View File
@@ -0,0 +1,9 @@
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": ["EE", "EE"],
"key": { "E": "example_mod:example_item" },
"result": { "id": "example_mod:example_block", "count": 1 }
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { ClientContext } from "bworld/client";
// called on every player's client after the mod is downloaded, before the world shows up.
// everything here is visible to players, so keep secrets and game rules in server.ts
export function setup(ctx: ClientContext) {
ctx.log("loaded");
// a HUD element, drawn every frame
// ctx.hud.register("example_mod:hint", {
// on_render(g) {
// g.text("example mod is here", 8, 8);
// },
// });
}
+26
View File
@@ -0,0 +1,26 @@
import type { ServerContext } from "bworld/server";
// called once when the server starts
export function setup(ctx: ServerContext) {
// used by blocks/example_block.json, which passes { message } as params
ctx.components.register_block<{ message: string }>("example_mod:announce", {
on_interact(_block, params, player) {
player.send_message(params.message);
// handled, so right clicking it doesn't place a block
return true;
},
});
// /example_mod:hello, or /hello when no other mod uses that name
ctx.commands.register("hello", {
description: "Say hello",
usage: "/hello",
run(_args, player) {
player.send_message(`Hello ${player.name}!`);
},
});
ctx.events.after.player_join.subscribe(({ player }) => {
ctx.log(`${player.name} joined`);
});
}
+15
View File
@@ -0,0 +1,15 @@
import type { WorldgenContext } from "bworld/worldgen";
// runs in chunk workers on the server and every client, which must all generate the same world.
// only use chunk.rng and the noise helpers, never Math.random or the time
export function setup(gen: WorldgenContext) {
// a boulder on one chunk in ten
gen.register_feature("example_mod:boulders", (chunk) => {
if (chunk.rng.next() > 0.1) {
return;
}
const x = chunk.x * 16 + Math.floor(chunk.rng.next() * 16);
const z = chunk.z * 16 + Math.floor(chunk.rng.next() * 16);
chunk.set_block(x, chunk.height_at(x, z) + 1, z, "bworld:stone");
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

+65
View File
@@ -0,0 +1,65 @@
import { assertEquals } from "@std/assert";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import {
block_from_json,
block_to_json,
grid_recipe_from_json,
grid_recipe_to_json,
item_from_json,
item_to_json,
} from "$/common/mod_data.ts";
import { CRAFTING_RECIPES } from "$/server/game/crafting.ts";
import { bworld_data_files } from "$/tools/export_bworld_mod.ts";
// mods/bworld is generated from the typescript definitions until the loader exists, keep them in sync
Deno.test("mods/bworld matches what the game registers", () => {
const expected = new Map(bworld_data_files().map(({ path, content }) => [path, content]));
const actual = new Map<string, unknown>();
for (const folder of ["blocks", "items", "recipes"]) {
for (const file of Deno.readDirSync(`mods/bworld/${folder}`)) {
actual.set(
`${folder}/${file.name}`,
JSON.parse(Deno.readTextFileSync(`mods/bworld/${folder}/${file.name}`)),
);
}
}
assertEquals(
[...actual.keys()].sort(),
[...expected.keys()].sort(),
"files differ, run deno task export-bworld",
);
for (const [path, content] of expected) {
assertEquals(actual.get(path), content, `${path} is out of date, run deno task export-bworld`);
}
});
// what the loader will do with the json has to give back exactly what the game uses now
Deno.test("blocks survive going to json and back", () => {
const items = new Map(EverythingRegistry.entries<ItemRegistry>("items"));
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("blocks")) {
const has_item = items.get(id)?.block_id === id;
const back = block_from_json(block_to_json(block, has_item));
assertEquals(back.block, without_undefined(block), id);
assertEquals(back.has_item, has_item, id);
}
});
Deno.test("items survive going to json and back", () => {
for (const [id, item] of EverythingRegistry.entries<ItemRegistry>("items")) {
if (item.block_id !== undefined) continue;
const { on_create: _on_create, get_lore: _get_lore, ...data } = item;
assertEquals(item_from_json(item_to_json(id, item)), without_undefined(data), id);
}
});
Deno.test("crafting recipes survive going to json and back", () => {
for (const recipe of CRAFTING_RECIPES) {
const json = grid_recipe_to_json(recipe);
if (json.type !== "shaped") throw new Error("expected a shaped recipe");
assertEquals(grid_recipe_from_json(json), recipe, recipe.result.id);
}
});
function without_undefined<T extends object>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;
}
+103
View File
@@ -0,0 +1,103 @@
import { assert, assertEquals, assertThrows } from "@std/assert";
import { check_mods } from "$/tools/check_mods.ts";
import { create_mod } from "$/tools/new_mod.ts";
function temp_mods_dir() {
const dir = Deno.makeTempDirSync({ prefix: "bworld_mods_" });
// most mods depend on the base game
Deno.mkdirSync(`${dir}/bworld`);
for (const entry of ["manifest.json", "blocks", "items", "recipes"]) {
copy(`mods/bworld/${entry}`, `${dir}/bworld/${entry}`);
}
return dir;
}
function copy(from: string, to: string) {
if (Deno.statSync(from).isDirectory) {
Deno.mkdirSync(to, { recursive: true });
for (const entry of Deno.readDirSync(from)) copy(`${from}/${entry.name}`, `${to}/${entry.name}`);
} else {
Deno.copyFileSync(from, to);
}
}
function write_json(path: string, value: unknown) {
Deno.writeTextFileSync(path, JSON.stringify(value));
}
Deno.test("a mod made from the template passes the checks", async () => {
const dir = temp_mods_dir();
create_mod("copper_tools", "Copper Tools", dir);
const manifest = JSON.parse(Deno.readTextFileSync(`${dir}/copper_tools/manifest.json`));
assertEquals(manifest.id, "copper_tools");
assertEquals(manifest.name, "Copper Tools");
assert(!Deno.readTextFileSync(`${dir}/copper_tools/blocks/example_block.json`).includes("example_mod"));
const report = (await check_mods(dir)).find((r) => r.id === "copper_tools")!;
assertEquals(report.errors, []);
assertEquals(report.warnings, []);
Deno.removeSync(dir, { recursive: true });
});
Deno.test("new-mod rejects bad and reserved ids", () => {
const dir = temp_mods_dir();
assertThrows(() => create_mod("Has Spaces", "x", dir));
assertThrows(() => create_mod("bworld", "x", dir));
assertThrows(() => create_mod("engine", "x", dir));
create_mod("once", "x", dir);
assertThrows(() => create_mod("once", "x", dir));
Deno.removeSync(dir, { recursive: true });
});
Deno.test("check-mods finds broken mods", async () => {
const dir = temp_mods_dir();
create_mod("broken", "Broken", dir);
const mod = `${dir}/broken`;
// wrong namespace, a drop that doesn't exist, and a texture that doesn't exist
write_json(`${mod}/blocks/wrong.json`, {
format_version: 1,
block: { id: "other:thing", textures: "broken:nope", drops: "broken:no_such_item" },
});
// defines a block the base game already has
write_json(`${mod}/blocks/dupe.json`, { format_version: 1, block: { id: "broken:x", textures: "bworld:stone" } });
write_json(`${mod}/blocks/stone_copy.json`, {
format_version: 1,
block: { id: "broken:x", textures: "bworld:stone" },
});
// not json
Deno.writeTextFileSync(`${mod}/items/bad.json`, "{ nope");
// uses a letter the key doesn't define
write_json(`${mod}/recipes/bad.json`, {
format_version: 1,
recipe: {
type: "shaped",
pattern: ["AB"],
key: { A: "bworld:stone" },
result: { id: "bworld:stone", count: 1 },
},
});
// wrong texture size
Deno.copyFileSync("assets/sprites/ui.png", `${mod}/textures/huge.png`);
// a script that doesn't typecheck
Deno.writeTextFileSync(`${mod}/scripts/client.ts`, "export function setup(ctx: number) { ctx.nope(); }\n");
// a dependency that isn't installed, and no longer depending on bworld
const manifest = JSON.parse(Deno.readTextFileSync(`${mod}/manifest.json`));
manifest.dependencies = [{ id: "missing_mod", version: "1.0.0" }];
write_json(`${mod}/manifest.json`, manifest);
const report = (await check_mods(dir)).find((r) => r.id === "broken")!;
const has = (list: string[], text: string) =>
assert(list.some((e) => e.includes(text)), `expected "${text}" in\n${list.join("\n")}`);
has(report.errors, `other:thing isn't in this mod's namespace`);
has(report.errors, "item broken:no_such_item doesn't exist");
has(report.errors, "block broken:x is also defined by broken, broken");
has(report.errors, "items/bad.json: isn't valid json");
has(report.errors, `pattern uses "B" but key doesn't define it`);
has(report.errors, "textures/huge.png: is");
has(report.errors, "scripts don't typecheck");
has(report.errors, `depends on "missing_mod", which isn't installed`);
has(report.warnings, "texture broken:nope doesn't exist");
has(report.warnings, `uses bworld:stone but doesn't list "bworld" in dependencies`);
Deno.removeSync(dir, { recursive: true });
});
+295
View File
@@ -0,0 +1,295 @@
// deno task check-mods
// validates every mod in mods/: manifests, data files, textures, references between them, and typechecks scripts
import {
BlockJson,
FORMAT_VERSION,
ItemJson,
RecipeJson,
validate_block,
validate_item,
validate_manifest,
validate_recipe,
} from "$/common/mod_data.ts";
export interface ModReport {
id: string;
errors: string[];
warnings: string[];
}
interface LoadedMod {
id: string;
dir: string;
report: ModReport;
manifest?: Record<string, unknown>;
blocks: { file: string; json: BlockJson }[];
items: { file: string; json: ItemJson }[];
recipes: { file: string; json: RecipeJson }[];
textures: string[];
}
// the base game's textures are still built from assets/ until the loader exists (phase 1 in MODS.md),
// and the build names all of them bworld:<file>
const BASE_TEXTURE_DIR = "assets/sprites/textures";
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
const mods: LoadedMod[] = [];
for (const entry of safe_read_dir(mods_dir)) {
if (entry.isDirectory && !entry.name.startsWith(".") && !entry.name.startsWith("_")) {
mods.push(load_mod(mods_dir, entry.name));
}
}
check_references(mods);
if (options.typecheck) {
for (const mod of mods) {
await typecheck_scripts(mod);
}
}
return mods.map((mod) => mod.report);
}
function load_mod(mods_dir: string, id: string): LoadedMod {
const dir = `${mods_dir}/${id}`;
const mod: LoadedMod = {
id,
dir,
report: { id, errors: [], warnings: [] },
blocks: [],
items: [],
recipes: [],
textures: [],
};
const error = (message: string) => mod.report.errors.push(message);
const manifest = read_json(`${dir}/manifest.json`, error);
if (manifest !== undefined) {
for (const problem of validate_manifest(manifest, id)) error(`manifest.json: ${problem}`);
mod.manifest = manifest as Record<string, unknown>;
}
if (id === "engine") {
error(`"engine" is reserved for the engine itself`);
}
const scripts = (mod.manifest?.scripts ?? {}) as Record<string, string>;
for (const [side, path] of Object.entries(scripts)) {
if (typeof path === "string" && !exists(`${dir}/${path}`)) {
error(`manifest.json: scripts.${side} ${path} doesn't exist`);
}
}
const credits = mod.manifest?.credits;
if (typeof credits === "string" && !exists(`${dir}/${credits}`)) {
error(`manifest.json: credits ${credits} doesn't exist`);
}
const load_data = <T>(
folder: string,
key: string,
validate: (json: unknown) => string[],
into: { file: string; json: T }[],
) => {
for (const file of walk(`${dir}/${folder}`, ".json")) {
const path = `${folder}/${file}`;
const wrapper = read_json(`${dir}/${path}`, (m) => error(`${path}: ${m}`));
if (wrapper === undefined) continue;
if (!is_object(wrapper) || wrapper.format_version !== FORMAT_VERSION || !(key in wrapper)) {
error(`${path}: must be { "format_version": ${FORMAT_VERSION}, "${key}": { ... } }`);
continue;
}
const problems = validate(wrapper[key]);
for (const problem of problems) error(`${path}: ${problem}`);
if (problems.length === 0) into.push({ file: path, json: wrapper[key] as T });
}
};
load_data("blocks", "block", validate_block, mod.blocks);
load_data("items", "item", validate_item, mod.items);
load_data("recipes", "recipe", validate_recipe, mod.recipes);
// a mod only registers ids in its own namespace
for (const { file, json } of [...mod.blocks, ...mod.items]) {
if (json.id.split(":")[0] !== id) error(`${file}: ${json.id} isn't in this mod's namespace "${id}"`);
}
for (const file of walk(`${dir}/textures`, ".png")) {
const texture_id = `${id}:${file.replace(/\.png$/, "").replaceAll("/", "_")}`;
mod.textures.push(texture_id);
const size = png_size(`${dir}/textures/${file}`);
if (!size) {
error(`textures/${file}: isn't a png`);
} else if (size.width !== 16 || size.height !== 16) {
error(`textures/${file}: is ${size.width}×${size.height}, textures must be 16×16`);
}
}
return mod;
}
function check_references(mods: LoadedMod[]) {
const block_owners = new Map<string, string[]>();
const item_owners = new Map<string, string[]>();
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
const textures = new Set<string>();
for (const file of walk(BASE_TEXTURE_DIR, ".png")) {
textures.add(`bworld:${file.replace(/\.png$/, "")}`);
}
for (const mod of mods) {
for (const { json } of mod.blocks) {
add(block_owners, json.id, mod.id);
if (json.item !== false) add(item_owners, json.id, mod.id);
}
for (const { json } of mod.items) add(item_owners, json.id, mod.id);
for (const texture of mod.textures) textures.add(texture);
}
const mod_ids = new Set(mods.map((mod) => mod.id));
for (const mod of mods) {
const { errors, warnings } = mod.report;
const dependencies = new Set(
((mod.manifest?.dependencies ?? []) as { id: string }[]).map((dep) => dep.id),
);
for (const dep of dependencies) {
if (!mod_ids.has(dep)) errors.push(`manifest.json: depends on "${dep}", which isn't installed`);
}
const uses = (file: string, id: string, what: "block" | "item" | "texture") => {
const namespace = id.split(":")[0];
if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) {
warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`);
}
if (what === "texture") {
if (!textures.has(id)) warnings.push(`${file}: texture ${id} doesn't exist, it will show as missing`);
} else if (!(what === "block" ? block_owners : item_owners).has(id)) {
errors.push(`${file}: ${what} ${id} doesn't exist`);
}
};
for (const { file, json } of mod.blocks) {
if ((block_owners.get(json.id)?.length ?? 0) > 1) {
errors.push(`${file}: block ${json.id} is also defined by ${block_owners.get(json.id)!.join(", ")}`);
}
const texture_ids = typeof json.textures === "string" ? [json.textures] : Object.values(json.textures);
for (const texture of texture_ids) uses(file, texture, "texture");
if (json.drops) uses(file, json.drops, "item");
}
for (const { file, json } of mod.items) {
if ((item_owners.get(json.id)?.length ?? 0) > 1) {
errors.push(`${file}: item ${json.id} is also defined by ${item_owners.get(json.id)!.join(", ")}`);
}
uses(file, json.texture, "texture");
if (json.places) uses(file, json.places, "block");
}
for (const { file, json } of mod.recipes) {
switch (json.type) {
case "shaped":
for (const id of Object.values(json.key)) uses(file, id, "item");
uses(file, json.result.id, "item");
break;
case "furnace":
uses(file, json.input, "item");
uses(file, json.output.id, "item");
break;
case "fuel":
uses(file, json.item, "item");
break;
}
}
}
}
async function typecheck_scripts(mod: LoadedMod) {
const scripts = Object.values((mod.manifest?.scripts ?? {}) as Record<string, string>)
.filter((path) => typeof path === "string" && exists(`${mod.dir}/${path}`))
.map((path) => `${mod.dir}/${path}`);
if (scripts.length === 0) return;
const output = await new Deno.Command(Deno.execPath(), {
args: ["check", "--config", "deno.json", ...scripts],
stdout: "piped",
stderr: "piped",
env: { NO_COLOR: "1" },
}).output();
if (!output.success) {
const text = new TextDecoder().decode(output.stderr).trim();
mod.report.errors.push(`scripts don't typecheck:\n${text}`);
}
}
// helpers
function read_json(path: string, error: (message: string) => void): unknown {
let text: string;
try {
text = Deno.readTextFileSync(path);
} catch {
error(`${path.split("/").pop()} is missing`);
return undefined;
}
try {
return JSON.parse(text);
} catch (e) {
error(`isn't valid json: ${(e as Error).message}`);
return undefined;
}
}
// relative paths of files ending in `extension`, in subfolders too
function walk(dir: string, extension: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of safe_read_dir(dir)) {
if (entry.isDirectory) {
files.push(...walk(`${dir}/${entry.name}`, extension, `${prefix}${entry.name}/`));
} else if (entry.name.endsWith(extension)) {
files.push(`${prefix}${entry.name}`);
}
}
return files.sort();
}
function safe_read_dir(dir: string): Deno.DirEntry[] {
try {
return [...Deno.readDirSync(dir)].sort((a, b) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
function exists(path: string) {
try {
Deno.statSync(path);
return true;
} catch {
return false;
}
}
// reads the size out of the png header instead of decoding the image
function png_size(path: string): { width: number; height: number } | undefined {
const bytes = Deno.readFileSync(path);
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (bytes.length < 24 || !signature.every((b, i) => bytes[i] === b)) return undefined;
const view = new DataView(bytes.buffer, bytes.byteOffset);
return { width: view.getUint32(16), height: view.getUint32(20) };
}
function is_object(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
if (import.meta.main) {
const reports = await check_mods();
let errors = 0;
for (const report of reports) {
const status = report.errors.length ? "✗" : "✓";
console.log(`${status} ${report.id}`);
for (const error of report.errors) console.log(` error: ${error.replaceAll("\n", "\n ")}`);
for (const warning of report.warnings) console.log(` warning: ${warning}`);
errors += report.errors.length;
}
if (reports.length === 0) console.log("No mods in mods/");
Deno.exit(errors ? 1 : 0);
}
+85
View File
@@ -0,0 +1,85 @@
// deno task export-bworld
// writes the base game's blocks, items and recipes into mods/bworld as json, from what the game registers now.
// the game still loads the typescript definitions until the mod loader exists (phase 1 in MODS.md),
// so run this again after changing them. tests/bworld_mod_test.ts fails when the two drift apart
import "$/common/blocks/mod.ts";
import "$/common/items/mod.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { block_to_json, grid_recipe_to_json, item_to_json, RecipeJson } from "$/common/mod_data.ts";
import { CRAFTING_RECIPES } from "$/server/game/crafting.ts";
import { FUEL_VALUES, FURNACE_RECIPES } from "$/server/game/blocks.ts";
const MOD_DIR = "mods/bworld";
const DATA_FOLDERS = ["blocks", "items", "recipes"];
function name_of(id: string) {
return id.split(":")[1];
}
// everything the base game registers, as the json files mods/bworld should contain
export function bworld_data_files(): { path: string; content: unknown }[] {
const files: { path: string; content: unknown }[] = [];
const add = (folder: string, name: string, key: string, content: unknown) =>
files.push({ path: `${folder}/${name}.json`, content: { format_version: 1, [key]: content } });
const items = new Map(EverythingRegistry.entries<ItemRegistry>("items"));
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("blocks")) {
const has_item = items.get(id)?.block_id === id;
add("blocks", name_of(id), "block", block_to_json(block, has_item));
}
for (const [id, item] of items) {
// block items come from their block's "item" field
if (item.block_id === undefined) {
add("items", name_of(id), "item", item_to_json(id, item));
}
}
for (const recipe of CRAFTING_RECIPES) {
add("recipes", `crafting_${name_of(recipe.result.id)}`, "recipe", grid_recipe_to_json(recipe));
}
for (const recipe of FURNACE_RECIPES) {
add(
"recipes",
`smelting_${name_of(recipe.input)}`,
"recipe",
{
type: "furnace",
input: recipe.input,
output: { id: recipe.output.type_id, count: recipe.output.amount },
cook_time: recipe.cook_time,
} satisfies RecipeJson,
);
}
for (const [item, burn_time] of Object.entries(FUEL_VALUES)) {
add("recipes", `fuel_${name_of(item)}`, "recipe", { type: "fuel", item, burn_time } satisfies RecipeJson);
}
return files;
}
if (import.meta.main) {
for (const folder of DATA_FOLDERS) {
try {
Deno.removeSync(`${MOD_DIR}/${folder}`, { recursive: true });
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) throw e;
}
Deno.mkdirSync(`${MOD_DIR}/${folder}`, { recursive: true });
}
const files = bworld_data_files();
for (const { path, content } of files) {
Deno.writeTextFileSync(`${MOD_DIR}/${path}`, JSON.stringify(content, null, "\t") + "\n");
}
// match the repo's formatting so reruns don't show up as changes
await new Deno.Command(Deno.execPath(), { args: ["fmt", "--quiet", ...DATA_FOLDERS.map((f) => `${MOD_DIR}/${f}`)] })
.output();
const count = (folder: string) => files.filter((f) => f.path.startsWith(`${folder}/`)).length;
console.log(
`Wrote ${count("blocks")} blocks, ${count("items")} items and ${count("recipes")} recipes to ${MOD_DIR}`,
);
}
+58
View File
@@ -0,0 +1,58 @@
// deno task new-mod <id> ["Display Name"]
// copies templates/mod into mods/<id>, with the template's placeholder id and name replaced
import { NAMESPACE_PATTERN, RESERVED_NAMESPACES } from "$/common/mod_data.ts";
const TEMPLATE = "templates/mod";
const PLACEHOLDER_ID = "example_mod";
const PLACEHOLDER_NAME = "Example Mod";
const TEXT_FILES = /\.(json|md|ts|js)$/;
export function create_mod(id: string, name: string, mods_dir = "mods") {
if (!NAMESPACE_PATTERN.test(id)) {
throw new Error(`"${id}" isn't a valid mod id: use 1-32 characters of a-z, 0-9 and _`);
}
if (RESERVED_NAMESPACES.includes(id)) {
throw new Error(`"${id}" is reserved`);
}
const target = `${mods_dir}/${id}`;
try {
Deno.statSync(target);
throw new Error(`${target} already exists`);
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) throw e;
}
copy_dir(TEMPLATE, target, (text) => text.replaceAll(PLACEHOLDER_ID, id).replaceAll(PLACEHOLDER_NAME, name));
return target;
}
function copy_dir(from: string, to: string, transform: (text: string) => string) {
Deno.mkdirSync(to, { recursive: true });
for (const entry of Deno.readDirSync(from)) {
const source = `${from}/${entry.name}`;
const destination = `${to}/${entry.name}`;
if (entry.isDirectory) {
copy_dir(source, destination, transform);
} else if (TEXT_FILES.test(entry.name)) {
Deno.writeTextFileSync(destination, transform(Deno.readTextFileSync(source)));
} else {
Deno.copyFileSync(source, destination);
}
}
}
if (import.meta.main) {
const [id, name] = Deno.args;
if (!id) {
console.error('Usage: deno task new-mod <id> ["Display Name"]');
Deno.exit(1);
}
const display_name = name ?? id.split("_").map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
try {
const path = create_mod(id, display_name);
console.log(`Created ${path}. Edit its manifest.json, then run deno task check-mods.`);
console.log("Note: the game can't load mods yet, see the implementation plan in MODS.md.");
} catch (e) {
console.error((e as Error).message);
Deno.exit(1);
}
}