Basic crops system

This commit is contained in:
2026-09-25 22:49:27 -03:00
parent ef25e66f08
commit 099811670f
22 changed files with 985 additions and 178 deletions
+83 -3
View File
@@ -20,6 +20,7 @@ too, since the client is already a web page.
- [Identifiers](#identifiers) - [Identifiers](#identifiers)
- [Textures](#textures) - [Textures](#textures)
- [Blocks](#blocks) - [Blocks](#blocks)
- [Block models](#block-models)
- [Items](#items) - [Items](#items)
- [Recipes](#recipes) - [Recipes](#recipes)
- [Server scripts](#server-scripts) - [Server scripts](#server-scripts)
@@ -89,6 +90,7 @@ mods/
copper_tools/ copper_tools/
manifest.json manifest.json
blocks/*.json blocks/*.json
models/*.json
items/*.json items/*.json
recipes/*.json recipes/*.json
worldgen/ores.json worldgen/ores.json
@@ -194,7 +196,9 @@ 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 }`. With another `model`, the model's texture variables, like `{ "crop": "..." }`. |
| `model` | `engine:cube` | `model` | The [block model](#block-models). |
| `variants` | none | `variants` | A different `model`, `textures` or `y` rotation for some states, see [block models](#block-models). |
| `render_layer` | `solid` | `render_layer` | `solid`, `cutout` (texels fully opaque or fully clear, like leaves) or `translucent` (blended, like water and glass). Non-solid blocks don't hide their neighbors' faces. | | `render_layer` | `solid` | `render_layer` | `solid`, `cutout` (texels fully opaque or fully clear, like leaves) or `translucent` (blended, like water and glass). Non-solid blocks don't hide their neighbors' faces. |
| `cull_same` | see meaning | `cull_same` | Hide faces between two of this block. Defaults to `true` for translucent blocks, `false` otherwise. | | `cull_same` | see meaning | `cull_same` | Hide faces between two of this block. Defaults to `true` for translucent blocks, `false` otherwise. |
| `light_emission` | `0` | `light_emission` | Light level 0-15 it gives off, like a torch (14). | | `light_emission` | `0` | `light_emission` | Light level 0-15 it gives off, like a torch (14). |
@@ -216,8 +220,84 @@ functions.
Block states are declared like `[{ "name": "facing", "bits": 2, "default": 0 }]`, at most 16 bits in total. A block Block states are declared like `[{ "name": "facing", "bits": 2, "default": 0 }]`, at most 16 bits in total. A block
starts with its defaults when it's placed or generated, and server scripts read and change them with starts with its defaults when it's placed or generated, and server scripts read and change them with
`ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. They can't change textures `ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. `variants` changes how the
yet, because the mesher ignores them. `variants` is reserved for that. block looks with them.
## Block models
A block's shape comes from its model, `engine:cube` unless it sets `model`. The engine has three:
| Model | Texture variables | Shape |
| -------------- | -------------------------------------------------- | --------------------------------------------- |
| `engine:cube` | one texture, `{ top, bottom, side }` or `{ front, side }` | A full block. |
| `engine:cross` | `cross` | Two crossed planes, like flowers and saplings. |
| `engine:crop` | `crop` | Four planes in a # shape, like wheat. |
Blocks that aren't full cubes should use the `cutout` (or `translucent`) render layer, because `solid` blocks hide their
neighbors' faces. This is what minecraft's `"render_type": "minecraft:cutout"` does. They usually want
`"collision": false` too, and show as a flat sprite of their first texture in inventories.
`blocks/wheat.json` picks a texture per growth stage with `variants`. The keys are conditions on the block's states,
`"age=3"` or `"age=3,facing=1"` (all have to match), and the first variant that matches replaces the block's `model`,
`textures` or turns it by `y` (0, 90, 180 or 270 degrees, clockwise seen from above):
```json
{
"format_version": 1,
"block": {
"id": "bworld:wheat",
"model": "engine:crop",
"textures": { "crop": "bworld:wheat_stage_0" },
"variants": {
"age=0": { "textures": { "crop": "bworld:wheat_stage_0" } },
"age=1": { "textures": { "crop": "bworld:wheat_stage_1" } }
},
"render_layer": "cutout",
"collision": false,
"states": [{ "name": "age", "bits": 3, "default": 0 }]
}
}
```
Mods add their own models in `models/`, in minecraft's block model format: boxes in pixels (0-16 is the block) with a
texture per face. `models/slab.json`:
```json
{
"format_version": 1,
"model": {
"id": "copper_tools:slab",
"textures": { "top": "#side", "bottom": "#side" },
"elements": [{
"from": [0, 0, 0],
"to": [16, 8, 16],
"faces": {
"top": { "texture": "#top" },
"bottom": { "texture": "#bottom", "cullface": "bottom" },
"north": { "texture": "#side", "cullface": "north" },
"south": { "texture": "#side", "cullface": "south" },
"west": { "texture": "#side", "cullface": "west" },
"east": { "texture": "#side", "cullface": "east" }
}
}]
}
}
```
| Field | Meaning |
| ---------------------------- | ---------------------------------------------------------------------------------------------- |
| `id` | The model's id, in the mod's namespace. |
| `textures` | Defaults for texture variables. Values are texture ids or other variables (`"#side"`). |
| `elements[].from`, `to` | Opposite corners of the box, `[x, y, z]` in pixels. |
| `elements[].rotation` | `{ origin, axis, angle, rescale }`: turns the box around `origin` on `x`, `y` or `z` by -45, -22.5, 0, 22.5 or 45 degrees. `rescale` stretches it back to the block's width. |
| `elements[].shade` | Darker faces on the sides and bottom, `true` by default. Plants turn it off. |
| `elements[].faces` | `top`, `bottom`, `north`, `south`, `west` and `east`. Faces are only drawn from the front, so a flat plane needs one face each way. |
| `faces.*.texture` | A variable (`"#side"`, filled in by the block's `textures`) or a texture id. |
| `faces.*.uv` | `[u1, v1, u2, v2]`, the part of the texture in pixels. Defaults to the part the face covers. |
| `faces.*.cullface` | Hidden when the neighbor on that side is a solid block. Only for faces on the block's edge. |
Faces flat against the block's edge get smooth lighting and ambient occlusion like a full block's. Anything inside the
block is lit evenly with the block's own light.
## Items ## Items
+1
View File
@@ -190,6 +190,7 @@ async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
const data: ModData = { const data: ModData = {
blocks: mod.blocks.map((b) => b.json), blocks: mod.blocks.map((b) => b.json),
models: mod.models.map((m) => m.json),
items: mod.items.map((i) => i.json), items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json), recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json), ores: mod.ores.map((o) => o.json),
+2
View File
@@ -17,6 +17,7 @@ import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } fr
import { crosses_planes } from "../workers/translucent_sort.ts"; import { crosses_planes } from "../workers/translucent_sort.ts";
import { Camera } from "../camera.ts"; import { Camera } from "../camera.ts";
import type { Entity } from "../entity/entity.ts"; import type { Entity } from "../entity/entity.ts";
import type { ModelJson } from "$/common/block_models.ts";
export interface Block { export interface Block {
id: string; id: string;
@@ -111,6 +112,7 @@ export class ClientLevel {
type: "init", type: "init",
blocks_registry: strip_functions(blocks_registry), blocks_registry: strip_functions(blocks_registry),
block_ids, block_ids,
models: Object.fromEntries(EverythingRegistry.entries<ModelJson>("models")),
textures_info: AssetManager.instance.get("bworld:textures_info"), textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height }, image: { width: this.image.width, height: this.image.height },
worldgen_scripts: worldgen_mods.scripts, worldgen_scripts: worldgen_mods.scripts,
+5 -13
View File
@@ -4,6 +4,7 @@ import { AssetManager } from "$/client/assets.ts";
import { get_sprite_region } from "$/client/sprites.ts"; import { get_sprite_region } from "$/client/sprites.ts";
import type { ItemEntity } from "$/client/entity/item_entity.ts"; import type { ItemEntity } from "$/client/entity/item_entity.ts";
import { push_vertex, Texture } from "$/client/renderer/mod.ts"; import { push_vertex, Texture } from "$/client/renderer/mod.ts";
import { block_item_texture, cube_face_texture } from "$/common/block_models.ts";
// how big items on the ground are drawn, minecraft's ground transform // how big items on the ground are drawn, minecraft's ground transform
const BLOCK_SCALE = 0.25; const BLOCK_SCALE = 0.25;
@@ -40,9 +41,11 @@ function copies(amount: number) {
export function render_item_entity(entity: ItemEntity, partial_tick: number) { export function render_item_entity(entity: ItemEntity, partial_tick: number) {
const atlas = AssetManager.instance.get<Texture>("bworld:textures"); const atlas = AssetManager.instance.get<Texture>("bworld:textures");
const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id); const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id);
const block_info = item_info?.block_id // blocks that aren't cubes, like plants, are drawn as their item's sprite
let block_info = item_info?.block_id
? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id) ? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
: undefined; : undefined;
if (block_info && block_item_texture(block_info) !== undefined) block_info = undefined;
// spinning and bobbing, like minecraft's ItemEntityRenderer // spinning and bobbing, like minecraft's ItemEntityRenderer
const time = entity.age + partial_tick; const time = entity.age + partial_tick;
@@ -79,7 +82,7 @@ function push_block(
}; };
for (const face of CUBE_FACES) { for (const face of CUBE_FACES) {
const region = get_sprite_region(block_face_texture(block, face.texture)); const region = get_sprite_region(cube_face_texture(block, face.texture));
push_textured_quad(atlas, region, face.corners.map(([cx, cy, cz]) => corner(cx, cy, cz)), face.shade); push_textured_quad(atlas, region, face.corners.map(([cx, cy, cz]) => corner(cx, cy, cz)), face.shade);
} }
} }
@@ -121,17 +124,6 @@ function push_textured_quad(atlas: Texture, region: { x: number; y: number }, co
} }
} }
function block_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
const textures = block.textures;
if (typeof textures === "string") {
return textures;
}
if ("top" in textures) {
return face === "top" ? textures.top : face === "bottom" ? textures.bottom : textures.side;
}
return face === "front" ? textures.front : textures.side;
}
function item_texture(entity: ItemEntity, item_info: ItemRegistry | undefined) { function item_texture(entity: ItemEntity, item_info: ItemRegistry | undefined) {
const texture_id = item_info?.texture_id; const texture_id = item_info?.texture_id;
if (typeof texture_id === "function") { if (typeof texture_id === "function") {
+11 -26
View File
@@ -3,6 +3,7 @@ import { get_sprite_region } from "$/client/sprites.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AssetManager } from "$/client/assets.ts"; import { AssetManager } from "$/client/assets.ts";
import { ItemStack } from "$/common/inventory.ts"; import { ItemStack } from "$/common/inventory.ts";
import { block_item_texture, cube_face_texture } from "$/common/block_models.ts";
import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts"; import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
export function draw_nine_slice( export function draw_nine_slice(
@@ -206,8 +207,12 @@ export function draw_item(item: ItemStack, x: number, y: number) {
throw Error(`Didn't find item registry for '${item.type_id}'`); throw Error(`Didn't find item registry for '${item.type_id}'`);
} }
if (item_info?.block_id) { const block_info = item_info.block_id
draw_item_block(item, item_info, x, y); ? EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id)
: undefined;
// blocks that aren't cubes, like plants, show a flat sprite
if (block_info && block_item_texture(block_info) === undefined) {
draw_item_block(block_info, x, y);
} else { } else {
draw_item_item(item, item_info, x, y); draw_item_item(item, item_info, x, y);
} }
@@ -251,30 +256,10 @@ function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y:
); );
} }
function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y: number) { function draw_item_block(block_info: BlockRegistry, x: number, y: number) {
const block_info = EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id!); const top_texture = cube_face_texture(block_info, "top");
const front_texture = cube_face_texture(block_info, "front");
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`); const left_texture = cube_face_texture(block_info, "side");
let front_texture = "engine:missing";
let top_texture = "engine:missing";
let left_texture = "engine:missing";
const textures = block_info.textures;
if (typeof textures === "string") {
front_texture = textures;
top_texture = textures;
left_texture = textures;
} else if ("top" in textures && "bottom" in textures && "side" in textures) {
top_texture = textures.top;
front_texture = textures.side;
left_texture = textures.side;
} else if ("front" in textures && "side" in textures) {
top_texture = textures.side;
front_texture = textures.front;
left_texture = textures.side;
}
const atlas = AssetManager.instance.get<Texture>("bworld:textures"); const atlas = AssetManager.instance.get<Texture>("bworld:textures");
+3
View File
@@ -1,6 +1,7 @@
import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts"; import type { SpriteRegion } from "$/common/constants.ts";
import type { OreJson } from "$/common/mod_data.ts"; import type { OreJson } from "$/common/mod_data.ts";
import type { ModelJson } from "$/common/block_models.ts";
import type { SortType } from "./translucent_sort.ts"; import type { SortType } from "./translucent_sort.ts";
// messages between the main thread and the chunk workers // messages between the main thread and the chunk workers
@@ -14,6 +15,8 @@ export type ToChunkWorker =
// the blocks registry without its functions, indexed by numeric id // the blocks registry without its functions, indexed by numeric id
blocks_registry: BlockRegistry[]; blocks_registry: BlockRegistry[];
block_ids: Record<string, number>; block_ids: Record<string, number>;
// mods' block models by id, the engine's are built in
models: Record<string, ModelJson>;
textures_info: Record<string, SpriteRegion>; textures_info: Record<string, SpriteRegion>;
image: { width: number; height: number }; image: { width: number; height: number };
// mods' worldgen scripts and ores, so generation matches the server // mods' worldgen scripts and ores, so generation matches the server
+164 -100
View File
@@ -6,12 +6,21 @@ import {
type BlockRegistry, type BlockRegistry,
type RenderLayer, type RenderLayer,
} from "$/common/everything_registry.ts"; } from "$/common/everything_registry.ts";
import { AIR, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK, type SpriteRegion, TEXTURE_SIZE } from "$/common/constants.ts"; import {
AIR,
CHUNK_AREA,
CHUNK_HEIGHT,
CHUNK_SIZE,
ID_MASK,
type SpriteRegion,
TEXTURE_SIZE,
} from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts"; import type { Texture } from "../renderer/types.ts";
import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS, type ToChunkWorker } from "./chunk_messages.ts"; import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS, type ToChunkWorker } from "./chunk_messages.ts";
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts"; import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts";
import { default_block_value } from "$/common/utils.ts"; import { default_block_value, get_state_value } from "$/common/utils.ts";
import { bake_model, block_variant, FACE_CORNERS, find_model, type ModelJson } from "$/common/block_models.ts";
import { import {
choose_sort_type, choose_sort_type,
FACE_NORMALS, FACE_NORMALS,
@@ -37,19 +46,7 @@ const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
// keeps texture lookups off the sprite's edge // keeps texture lookups off the sprite's edge
const UV_PAD = 0.5; const UV_PAD = 0.5;
// same order as FACE_NORMALS in translucent_sort.ts // same order as FACE_NORMALS in translucent_sort.ts, and FACE_CORNERS and CORNER_UVS in block_models.ts
const FACES = ["top", "bottom", "front", "back", "left", "right"] as const;
// each face's corners in drawing order (counter clockwise from outside), as offsets from the block's corner
const FACE_CORNERS = [
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
] as const;
// which end of the sprite each corner gets, u then v (0 = start, 1 = end)
const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
// minecraft's shading by direction, so faces stay apart even in flat light // minecraft's shading by direction, so faces stay apart even in flat light
const FACE_SHADE = [1.0, 0.5, 0.8, 0.8, 0.6, 0.6]; const FACE_SHADE = [1.0, 0.5, 0.8, 0.8, 0.6, 0.6];
@@ -86,6 +83,24 @@ const block_lets_light_by = new Uint8Array(TABLE_SIZE);
const light_tables: LightTables = { opacity: new Uint8Array(TABLE_SIZE), emission: new Uint8Array(TABLE_SIZE) }; const light_tables: LightTables = { opacity: new Uint8Array(TABLE_SIZE), emission: new Uint8Array(TABLE_SIZE) };
const region = new LightRegion(); const region = new LightRegion();
// a model's quad ready for the mesher: uvs in the atlas, and for each corner how much of each of the face's
// four corner lights it gets, so faces smaller than the block are lit like minecraft's
interface MeshQuad {
positions: number[];
uvs: number[];
face: number;
cull: number;
flush: boolean;
shade: number;
sprite: SpriteRegion;
// 4 weights per corner
light_weights: number[];
}
let models: Record<string, ModelJson> = {};
// by block value, or by numeric id for blocks without variants
const baked = new Map<number, MeshQuad[]>();
let textures_info: TexturesInfo = {}; let textures_info: TexturesInfo = {};
let image: Texture; let image: Texture;
let worldgen: WorldgenSetup | undefined; let worldgen: WorldgenSetup | undefined;
@@ -100,6 +115,8 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
case "init": case "init":
blocks_registry = message.blocks_registry; blocks_registry = message.blocks_registry;
block_ids = message.block_ids; block_ids = message.block_ids;
models = message.models;
baked.clear();
build_block_tables(); build_block_tables();
textures_info = message.textures_info; textures_info = message.textures_info;
image = message.image as Texture; image = message.image as Texture;
@@ -115,7 +132,12 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
case "mesh": { case "mesh": {
region.fill(message.chunks); region.fill(message.chunks);
region.compute(light_tables); region.compute(light_tables);
const { solid, cutout, translucent } = make_chunk_mesh(message.chunk_x, message.chunk_z, message.camera); const { solid, cutout, translucent } = make_chunk_mesh(
message.chunk_x,
message.chunk_z,
message.chunks[4]!,
message.camera,
);
post( post(
{ {
type: "meshed", type: "meshed",
@@ -263,34 +285,19 @@ function should_flip() {
return light(0) + light(2) > light(1) + light(3); return light(0) + light(2) > light(1) + light(3);
} }
function push_quad( function push_quad(vertices: Float32Array, i: number, quad: MeshQuad, x: number, y: number, z: number, alpha: number) {
vertices: Float32Array, const sprite = quad.sprite;
i: number,
face: number,
x: number,
y: number,
z: number,
sprite: SpriteRegion,
alpha: number,
) {
const u0 = (sprite.x * TEXTURE_SIZE + UV_PAD) / image.width;
const v0 = (sprite.y * TEXTURE_SIZE + UV_PAD) / image.height;
const u1 = ((sprite.x + 1) * TEXTURE_SIZE - UV_PAD) / image.width;
const v1 = ((sprite.y + 1) * TEXTURE_SIZE - UV_PAD) / image.height;
const shade = FACE_SHADE[face];
// starting from the second corner moves the diagonal, the winding stays the same // starting from the second corner moves the diagonal, the winding stays the same
const first = should_flip() ? 1 : 0; const first = should_flip() ? 1 : 0;
for (let k = 0; k < 4; k++) { for (let k = 0; k < 4; k++) {
const corner = (first + k) & 3; const corner = (first + k) & 3;
const [cx, cy, cz] = FACE_CORNERS[face][corner]; const brightness = quad.shade * corner_ao[corner];
const [cu, cv] = CORNER_UVS[corner]; vertices[i++] = x + quad.positions[corner * 3];
const brightness = shade * corner_ao[corner]; vertices[i++] = y + quad.positions[corner * 3 + 1];
vertices[i++] = x + cx; vertices[i++] = z + quad.positions[corner * 3 + 2];
vertices[i++] = y + cy; vertices[i++] = atlas_u(sprite, quad.uvs[corner * 2]);
vertices[i++] = z + cz; vertices[i++] = atlas_v(sprite, quad.uvs[corner * 2 + 1]);
vertices[i++] = cu ? u1 : u0;
vertices[i++] = cv ? v1 : v0;
vertices[i++] = brightness; vertices[i++] = brightness;
vertices[i++] = brightness; vertices[i++] = brightness;
vertices[i++] = brightness; vertices[i++] = brightness;
@@ -302,14 +309,111 @@ function push_quad(
return i; return i;
} }
// pixels of a sprite to atlas coordinates, kept off the sprite's edge
function atlas_u(sprite: SpriteRegion, u: number) {
return (sprite.x * TEXTURE_SIZE + Math.min(Math.max(u, UV_PAD), TEXTURE_SIZE - UV_PAD)) / image.width;
}
function atlas_v(sprite: SpriteRegion, v: number) {
return (sprite.y * TEXTURE_SIZE + Math.min(Math.max(v, UV_PAD), TEXTURE_SIZE - UV_PAD)) / image.height;
}
// the model quads for a block value, baked the first time it's seen
function block_quads(value: number): MeshQuad[] {
const nid = value & ID_MASK;
const block = blocks_registry[nid];
const key = block.variants ? value : nid;
let quads = baked.get(key);
if (quads) return quads;
let states: Record<string, number> | undefined;
if (block.variants && block.states) {
states = {};
for (const state of block.states) states[state.name] = get_state_value(value, block, state.name)!;
}
const variant = block_variant(block, states);
const model = find_model(variant.model, models) ?? find_model("engine:cube", models)!;
quads = bake_model(model, variant.textures, variant.y).map((quad) => ({
positions: quad.positions,
uvs: quad.uvs,
face: quad.face,
cull: quad.cull,
flush: quad.flush,
shade: quad.shade ? FACE_SHADE[quad.face] : 1,
sprite: textures_info[quad.texture] ?? textures_info["engine:missing"],
light_weights: light_weights(quad.face, quad.positions),
}));
baked.set(key, quads);
return quads;
}
// bilinear weights of the face's four corners at each of the quad's corners
function light_weights(face: number, positions: number[]) {
const axes = [0, 1, 2].filter((axis) => FACE_NORMALS[face][axis] === 0);
const weights: number[] = [];
for (let k = 0; k < 4; k++) {
for (const corner of FACE_CORNERS[face]) {
let weight = 1;
for (const axis of axes) {
const t = Math.min(Math.max(positions[k * 3 + axis], 0), 1);
weight *= corner[axis] ? t : 1 - t;
}
weights.push(weight);
}
}
return weights;
}
// light for a quad smaller than a face, or not on the block's edge at all
const face_sky = new Float32Array(4);
const face_block = new Float32Array(4);
const face_ao = new Float32Array(4);
function light_quad(quad: MeshQuad, index: number, y: number, face_offsets: number[]) {
if (quad.flush) {
light_face_corners(quad.face, index + face_offsets[quad.face], y + FACE_NORMALS[quad.face][1]);
face_sky.set(corner_sky);
face_block.set(corner_block);
face_ao.set(corner_ao);
const w = quad.light_weights;
for (let k = 0; k < 4; k++) {
let sky = 0;
let block = 0;
let ao = 0;
for (let c = 0; c < 4; c++) {
sky += w[k * 4 + c] * face_sky[c];
block += w[k * 4 + c] * face_block[c];
ao += w[k * 4 + c] * face_ao[c];
}
corner_sky[k] = sky;
corner_block[k] = block;
corner_ao[k] = ao;
}
return;
}
// inside the block: its own cell's light, or the cell it faces if the block stops light itself
let cell = index;
let cell_y = y;
if (light_tables.opacity[region_block(region, index, y)] === 15) {
cell = index + face_offsets[quad.face];
cell_y = y + FACE_NORMALS[quad.face][1];
}
corner_sky.fill(region_sky(region, cell, cell_y));
corner_block.fill(region_block_light(region, cell, cell_y));
corner_ao.fill(1);
}
// region has to be filled and lit first // region has to be filled and lit first
function make_chunk_mesh(chunk_x: number, chunk_z: number, camera: number[]) { // values is the middle chunk's blocks with their states, for models that change with them
function make_chunk_mesh(chunk_x: number, chunk_z: number, values: Uint32Array, camera: number[]) {
const layers = [SOLID, CUTOUT, TRANSLUCENT].map(() => ({ vertices: new Float32Array(4096), floats: 0 })); const layers = [SOLID, CUTOUT, TRANSLUCENT].map(() => ({ vertices: new Float32Array(4096), floats: 0 }));
// for sorting the translucent quads // for sorting the translucent quads
let centers = new Float32Array(256); let centers = new Float32Array(256);
let faces = new Uint8Array(256); let faces = new Uint8Array(256);
// where the neighbor on each face is, same order as FACES // where the neighbor on each face is, same order as FACE_NORMALS
const face_offsets = FACE_NORMALS.map(([nx, ny, nz]) => nx + ny * REGION_LAYER + nz * REGION_SIZE); const face_offsets = FACE_NORMALS.map(([nx, ny, nz]) => nx + ny * REGION_LAYER + nz * REGION_SIZE);
for (let y = 0; y < CHUNK_HEIGHT; y++) { for (let y = 0; y < CHUNK_HEIGHT; y++) {
@@ -325,73 +429,33 @@ function make_chunk_mesh(chunk_x: number, chunk_z: number, camera: number[]) {
const layer = layers[layer_id]; const layer = layers[layer_id];
const alpha = layer_id === TRANSLUCENT ? block_info.alpha ?? 1 : 1; const alpha = layer_id === TRANSLUCENT ? block_info.alpha ?? 1 : 1;
const texture_ids = {
top: "engine:missing",
bottom: "engine:missing",
front: "engine:missing",
back: "engine:missing",
left: "engine:missing",
right: "engine:missing",
};
const textures = block_info.textures;
if (!textures) throw new Error(`no textures for ${block_nid}`);
if (typeof textures === "string") {
texture_ids.top = textures;
texture_ids.bottom = textures;
texture_ids.front = textures;
texture_ids.back = textures;
texture_ids.left = textures;
texture_ids.right = textures;
} else if ("top" in textures && "bottom" in textures && "side" in textures) {
texture_ids.top = textures.top;
texture_ids.bottom = textures.bottom;
texture_ids.front = textures.side;
texture_ids.back = textures.side;
texture_ids.left = textures.side;
texture_ids.right = textures.side;
} else if ("front" in textures && "side" in textures) {
texture_ids.top = textures.side;
texture_ids.bottom = textures.side;
texture_ids.front = textures.front;
texture_ids.back = textures.side;
texture_ids.left = textures.side;
texture_ids.right = textures.side;
}
const wx = chunk_x * CHUNK_SIZE + x; const wx = chunk_x * CHUNK_SIZE + x;
const wz = chunk_z * CHUNK_SIZE + z; const wz = chunk_z * CHUNK_SIZE + z;
for (let face = 0; face < 6; face++) { for (const quad of block_quads(values[y * CHUNK_AREA + z * CHUNK_SIZE + x])) {
const front = index + face_offsets[face]; if (quad.cull >= 0) {
const front_y = y + FACE_NORMALS[face][1]; const neighbor = region_block(
if (!show_face(block_nid, region_block(region, front, front_y))) { region,
continue; index + face_offsets[quad.cull],
y + FACE_NORMALS[quad.cull][1],
);
if (!show_face(block_nid, neighbor)) continue;
} }
light_face_corners(face, front, front_y); light_quad(quad, index, y, face_offsets);
layer.vertices = ensure_capacity(layer.vertices, layer.floats + FLOATS_PER_QUAD); layer.vertices = ensure_capacity(layer.vertices, layer.floats + FLOATS_PER_QUAD);
layer.floats = push_quad( layer.floats = push_quad(layer.vertices, layer.floats, quad, wx, y, wz, alpha);
layer.vertices,
layer.floats,
face,
wx,
y,
wz,
textures_info[texture_ids[FACES[face]]],
alpha,
);
if (layer_id === TRANSLUCENT) { if (layer_id === TRANSLUCENT) {
const quad = layer.floats / FLOATS_PER_QUAD - 1; // sorting treats every quad as facing along an axis, rotated ones too
centers = ensure_capacity(centers, (quad + 1) * 3); const q = layer.floats / FLOATS_PER_QUAD - 1;
faces = ensure_capacity(faces, quad + 1); centers = ensure_capacity(centers, (q + 1) * 3);
const [nx, ny, nz] = FACE_NORMALS[face]; faces = ensure_capacity(faces, q + 1);
centers[quad * 3] = wx + 0.5 + nx * 0.5; const p = quad.positions;
centers[quad * 3 + 1] = y + 0.5 + ny * 0.5; centers[q * 3] = wx + (p[0] + p[6]) / 2;
centers[quad * 3 + 2] = wz + 0.5 + nz * 0.5; centers[q * 3 + 1] = y + (p[1] + p[7]) / 2;
faces[quad] = face; centers[q * 3 + 2] = wz + (p[2] + p[8]) / 2;
faces[q] = quad.face;
} }
} }
} }
+308
View File
@@ -0,0 +1,308 @@
// block models, in the shape of minecraft's block model json: boxes ("elements") with a texture per face.
// a block picks one with "model" and fills in its texture variables with "textures". baking turns a model
// into quads in block space (0 to 1) that the chunk mesher and the item renderers draw
import type { BlockRegistry, BlockTextures } from "./everything_registry.ts";
export type ModelFace = "top" | "bottom" | "north" | "south" | "west" | "east";
export const MODEL_FACES: ModelFace[] = ["top", "bottom", "north", "south", "west", "east"];
export interface ModelFaceJson {
// a texture variable like "#side", or a texture id
texture: string;
// the part of the texture, [u1, v1, u2, v2] in pixels (0-16). defaults to the part the face covers
uv?: [number, number, number, number];
// hidden when the neighbor on this side hides faces (a solid block)
cullface?: ModelFace;
}
export interface ModelElementJson {
// corners of the box in pixels, 0-16 is the block
from: [number, number, number];
to: [number, number, number];
rotation?: {
origin: [number, number, number];
axis: "x" | "y" | "z";
// -45, -22.5, 0, 22.5 or 45
angle: number;
// stretch the rotated faces back to the block's size, like the cross model does
rescale?: boolean;
};
// directional shading, off for plants so they look the same from every side
shade?: boolean;
faces: Partial<Record<ModelFace, ModelFaceJson>>;
}
export interface ModelJson {
id: string;
// defaults for texture variables, can point at other variables ("top": "#side")
textures?: Record<string, string>;
elements: ModelElementJson[];
}
// a model a block uses, and the textures it fills in
export interface BlockVariant {
model?: string;
textures?: BlockTextures;
// turns the model around the vertical axis, clockwise seen from above: 0, 90, 180 or 270
y?: number;
}
export const DEFAULT_MODEL = "engine:cube";
const cube_face = (texture: string, cullface: ModelFace): ModelFaceJson => ({ texture, cullface });
// the engine's own models
export const BUILTIN_MODELS: Record<string, ModelJson> = {
// a full block. "textures" can be one texture, { top, bottom, side } or { front, side }
"engine:cube": {
id: "engine:cube",
textures: { top: "#side", bottom: "#side", front: "#side" },
elements: [{
from: [0, 0, 0],
to: [16, 16, 16],
faces: {
top: cube_face("#top", "top"),
bottom: cube_face("#bottom", "bottom"),
north: cube_face("#side", "north"),
south: cube_face("#front", "south"),
west: cube_face("#side", "west"),
east: cube_face("#side", "east"),
},
}],
},
// two crossed planes, like flowers and saplings. uses the "cross" texture
"engine:cross": {
id: "engine:cross",
elements: [
cross_plane(45),
cross_plane(-45),
],
},
// four planes in a # shape, like wheat. uses the "crop" texture
"engine:crop": {
id: "engine:crop",
elements: [
{ from: [4, 0, 0], to: [4, 16, 16], shade: false, faces: both_ways("west", "east", "#crop") },
{ from: [12, 0, 0], to: [12, 16, 16], shade: false, faces: both_ways("west", "east", "#crop") },
{ from: [0, 0, 4], to: [16, 16, 4], shade: false, faces: both_ways("north", "south", "#crop") },
{ from: [0, 0, 12], to: [16, 16, 12], shade: false, faces: both_ways("north", "south", "#crop") },
],
},
};
function cross_plane(angle: number): ModelElementJson {
return {
from: [0.8, 0, 8],
to: [15.2, 16, 8],
rotation: { origin: [8, 8, 8], axis: "y", angle, rescale: true },
shade: false,
faces: both_ways("north", "south", "#cross"),
};
}
// a flat element seen from both sides, since faces are only drawn from the front
function both_ways(a: ModelFace, b: ModelFace, texture: string) {
return { [a]: { texture, uv: [0, 0, 16, 16] }, [b]: { texture, uv: [0, 0, 16, 16] } } as Partial<
Record<ModelFace, ModelFaceJson>
>;
}
export function find_model(id: string, models: Record<string, ModelJson>): ModelJson | undefined {
return BUILTIN_MODELS[id] ?? models[id];
}
// what a texture reference means for a block: "#name" looks up the block's textures, then the model's
// defaults, and anything else is already a texture id
export function resolve_texture(reference: string, textures: BlockTextures | undefined, model?: ModelJson): string {
for (let depth = 0; depth < 8 && reference.startsWith("#"); depth++) {
const name = reference.slice(1);
if (typeof textures === "string") return textures;
const next = (textures as Record<string, string> | undefined)?.[name] ?? model?.textures?.[name];
if (next === undefined) return "engine:missing";
reference = next;
}
return reference.startsWith("#") ? "engine:missing" : reference;
}
// the block's model and textures for a value with state bits, from the first variant whose conditions match
export function block_variant(block: BlockRegistry, states?: Record<string, number>): Required<BlockVariant> {
const variant: Required<BlockVariant> = {
model: block.model ?? DEFAULT_MODEL,
textures: block.textures,
y: 0,
};
if (!block.variants || !states) return variant;
for (const [condition, override] of Object.entries(block.variants)) {
if (variant_matches(condition, states)) {
return {
model: override.model ?? variant.model,
textures: override.textures ?? variant.textures,
y: override.y ?? 0,
};
}
}
return variant;
}
// "age=7" or "age=7,facing=2", "" matches everything
export function variant_matches(condition: string, states: Record<string, number>) {
if (condition === "") return true;
return condition.split(",").every((part) => {
const [name, value] = part.split("=");
return states[name.trim()] === Number(value);
});
}
// for inventories and dropped items: blocks with a full cube draw as a cube, anything else as a flat sprite
// of its first texture, like minecraft's items for plants
export function block_item_texture(block: BlockRegistry): string | undefined {
if ((block.model ?? DEFAULT_MODEL) === DEFAULT_MODEL) return undefined;
const textures = block.textures;
if (typeof textures === "string") return textures;
return Object.values(textures)[0] ?? "engine:missing";
}
export function cube_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
return resolve_texture(`#${face}`, block.textures, BUILTIN_MODELS[DEFAULT_MODEL]);
}
// baking
export interface BakedQuad {
// 4 corners counter clockwise seen from the front, x y z in block space
positions: number[];
// u v per corner, in pixels of the texture (0-16)
uvs: number[];
texture: string;
// the side it faces most, as the mesher's face index (see FACE_INDEX)
face: number;
// the side whose neighbor can hide it, or -1
cull: number;
// on the block's edge facing straight out, so it's lit like a full block's face
flush: boolean;
shade: boolean;
}
// the mesher's face order: top, bottom, front (+z), back (-z), left (-x), right (+x)
export const FACE_INDEX: Record<ModelFace, number> = { top: 0, bottom: 1, south: 2, north: 3, west: 4, east: 5 };
const FACE_NORMALS = [[0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1], [-1, 0, 0], [1, 0, 0]];
// each face's corners in drawing order as which end of the box they're at, same as the mesher
export const FACE_CORNERS = [
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
] as const;
// which end of the uv rectangle each corner gets, u then v
export const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
// the face a face becomes after turning the model 90 degrees clockwise seen from above
const TURN_Y = [0, 1, 4, 5, 3, 2];
// minecraft's default uvs: the part of the texture the face would cover if the texture was wrapped around the block
function default_uv(face: ModelFace, from: number[], to: number[]): [number, number, number, number] {
switch (face) {
case "top":
return [from[0], from[2], to[0], to[2]];
case "bottom":
return [from[0], 16 - to[2], to[0], 16 - from[2]];
case "north":
return [16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]];
case "south":
return [from[0], 16 - to[1], to[0], 16 - from[1]];
case "west":
return [from[2], 16 - to[1], to[2], 16 - from[1]];
case "east":
return [16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]];
}
}
const EPSILON = 1e-4;
export function bake_model(model: ModelJson, textures: BlockTextures | undefined, y_rotation = 0): BakedQuad[] {
const quads: BakedQuad[] = [];
const turns = ((Math.round(y_rotation / 90) % 4) + 4) % 4;
for (const element of model.elements) {
const rotate = element_rotation(element);
for (const face of MODEL_FACES) {
const face_json = element.faces[face];
if (!face_json) continue;
const index = FACE_INDEX[face];
const uv = face_json.uv ?? default_uv(face, element.from, element.to);
const positions: number[] = [];
const uvs: number[] = [];
FACE_CORNERS[index].forEach((corner, k) => {
let point = corner.map((end, axis) => (end ? element.to[axis] : element.from[axis]) / 16);
point = rotate(point);
for (let t = 0; t < turns; t++) point = [1 - point[2], point[1], point[0]];
positions.push(...point);
const [cu, cv] = CORNER_UVS[k];
uvs.push(cu ? uv[2] : uv[0], cv ? uv[3] : uv[1]);
});
let cull = face_json.cullface ? FACE_INDEX[face_json.cullface] : -1;
for (let t = 0; t < turns && cull >= 0; t++) cull = TURN_Y[cull];
const { face: facing, flush } = classify(positions);
quads.push({
positions,
uvs,
texture: resolve_texture(face_json.texture, textures, model),
face: facing,
cull,
flush,
shade: element.shade ?? true,
});
}
}
return quads;
}
// turns a point (in block space) by the element's rotation
function element_rotation(element: ModelElementJson): (point: number[]) => number[] {
const rotation = element.rotation;
if (!rotation || rotation.angle === 0) return (point) => point;
const axis = { x: 0, y: 1, z: 2 }[rotation.axis];
// the two axes that move, in the order that makes a positive angle counter clockwise looking down the axis
const [a, b] = axis === 0 ? [1, 2] : axis === 1 ? [2, 0] : [0, 1];
const radians = rotation.angle * Math.PI / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
// minecraft's rescale keeps a 45 degree face as wide as the block
const scale = rotation.rescale ? 1 / Math.max(Math.abs(cos), Math.abs(sin)) : 1;
const origin = rotation.origin.map((v) => v / 16);
return (point) => {
const out = [...point];
const da = point[a] - origin[a];
const db = point[b] - origin[b];
out[a] = origin[a] + (da * cos - db * sin) * scale;
out[b] = origin[b] + (da * sin + db * cos) * scale;
return out;
};
}
// which way a quad faces most, and whether it's flat against the block's edge
function classify(p: number[]): { face: number; flush: boolean } {
const e1 = [p[3] - p[0], p[4] - p[1], p[5] - p[2]];
const e2 = [p[9] - p[0], p[10] - p[1], p[11] - p[2]];
const normal = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
let face = 0;
let best = -Infinity;
FACE_NORMALS.forEach((n, i) => {
const dot = n[0] * normal[0] + n[1] * normal[1] + n[2] * normal[2];
if (dot > best) {
best = dot;
face = i;
}
});
const axis = FACE_NORMALS[face].findIndex((v) => v !== 0);
const edge = FACE_NORMALS[face][axis] > 0 ? 1 : 0;
const flush = [0, 1, 2, 3].every((k) => Math.abs(p[k * 3 + axis] - edge) < EPSILON);
return { face, flush };
}
+9 -17
View File
@@ -1,4 +1,5 @@
import type { ItemStack } from "./inventory.ts"; import type { ItemStack } from "./inventory.ts";
import type { BlockVariant } from "./block_models.ts";
export class EverythingRegistry { export class EverythingRegistry {
static #key_to_id = new Map<string, Map<string, number>>(); static #key_to_id = new Map<string, Map<string, number>>();
@@ -65,16 +66,9 @@ export class EverythingRegistry {
} }
} }
interface TextureSideTopBottom { // one texture for everything, or the model's texture variables: { top, bottom, side } or { front, side } for
top: string; // cubes, { crop } for engine:crop and so on
bottom: string; export type BlockTextures = string | Record<string, string>;
side: string;
}
interface TextureFront {
front: string;
side: string;
}
export interface BlockStateDefinition { export interface BlockStateDefinition {
name: string; name: string;
@@ -88,11 +82,6 @@ interface CompiledStateDefinition {
shift: number; shift: number;
} }
interface BlockStateVariant {
model: string;
y: number;
}
// solid: fully opaque. cutout: texels are either opaque or see-through (leaves). // solid: fully opaque. cutout: texels are either opaque or see-through (leaves).
// translucent: blended and sorted back to front (water, glass) // translucent: blended and sorted back to front (water, glass)
export const RENDER_LAYERS = ["solid", "cutout", "translucent"] as const; export const RENDER_LAYERS = ["solid", "cutout", "translucent"] as const;
@@ -110,7 +99,9 @@ export function block_light_emission(block: BlockRegistry | undefined): number {
export interface BlockRegistry { export interface BlockRegistry {
id: string; id: string;
textures: string | TextureSideTopBottom | TextureFront; textures: BlockTextures;
// the block model, engine:cube when not set. see common/block_models.ts
model?: string;
// solid when not set. anything else doesn't hide its neighbors' faces // solid when not set. anything else doesn't hide its neighbors' faces
render_layer?: RenderLayer; render_layer?: RenderLayer;
// hide faces between two of this block, like glass and water. defaults to true for translucent blocks // hide faces between two of this block, like glass and water. defaults to true for translucent blocks
@@ -128,7 +119,8 @@ export interface BlockRegistry {
drop_table?: string; drop_table?: string;
states?: BlockStateDefinition[]; states?: BlockStateDefinition[];
variants?: Record<string, BlockStateVariant>; // a different model or textures for some states, by conditions like "age=7". the first match wins
variants?: Record<string, BlockVariant>;
// 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
+106
View File
@@ -7,6 +7,7 @@ import {
RENDER_LAYERS, RENDER_LAYERS,
type RenderLayer, type RenderLayer,
} from "./everything_registry.ts"; } from "./everything_registry.ts";
import { type BlockVariant, MODEL_FACES, type ModelFace } from "./block_models.ts";
export const FORMAT_VERSION = 1; export const FORMAT_VERSION = 1;
export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
@@ -31,6 +32,8 @@ export interface ManifestJson {
export interface BlockJson { export interface BlockJson {
id: string; id: string;
textures: BlockTextures; textures: BlockTextures;
model?: string;
variants?: Record<string, BlockVariant>;
render_layer?: RenderLayer; render_layer?: RenderLayer;
cull_same?: boolean; cull_same?: boolean;
light_emission?: number; light_emission?: number;
@@ -89,6 +92,8 @@ export interface GridRecipe {
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson { export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
const json: BlockJson = { id: block.id, textures: block.textures }; const json: BlockJson = { id: block.id, textures: block.textures };
if (block.model !== undefined) json.model = block.model;
if (block.variants !== undefined) json.variants = block.variants;
if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer; if (block.render_layer && block.render_layer !== "solid") json.render_layer = block.render_layer;
if (block.cull_same !== undefined) json.cull_same = block.cull_same; if (block.cull_same !== undefined) json.cull_same = block.cull_same;
if (block.light_emission !== undefined) json.light_emission = block.light_emission; if (block.light_emission !== undefined) json.light_emission = block.light_emission;
@@ -115,6 +120,8 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it
textures: json.textures, textures: json.textures,
has_collision: json.collision ?? true, has_collision: json.collision ?? true,
}; };
if (json.model !== undefined) block.model = json.model;
if (json.variants !== undefined) block.variants = json.variants;
const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined); const render_layer = json.render_layer ?? (json.transparent ? "translucent" : undefined);
if (render_layer && render_layer !== "solid") block.render_layer = render_layer; if (render_layer && render_layer !== "solid") block.render_layer = render_layer;
if (json.cull_same !== undefined) block.cull_same = json.cull_same; if (json.cull_same !== undefined) block.cull_same = json.cull_same;
@@ -255,12 +262,45 @@ export function validate_manifest(json: unknown, folder_name: string): Problems
export function validate_block(json: unknown): Problems { export function validate_block(json: unknown): Problems {
if (!is_object(json)) return ["block must be an object"]; if (!is_object(json)) return ["block must be an object"];
const problems = validate_id(json.id, "id"); const problems = validate_id(json.id, "id");
if (json.model !== undefined) problems.push(...validate_id(json.model, "model"));
const is_cube = json.model === undefined || json.model === "engine:cube";
const textures = json.textures; const textures = json.textures;
if (is_cube) {
const texture_ok = typeof textures === "string" || const texture_ok = typeof textures === "string" ||
(is_object(textures) && (is_object(textures) &&
(["top", "bottom", "side"].every((k) => typeof textures[k] === "string") || (["top", "bottom", "side"].every((k) => typeof textures[k] === "string") ||
["front", "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 }"); if (!texture_ok) problems.push("textures must be a texture id, { top, bottom, side } or { front, side }");
} else {
problems.push(...validate_textures(textures, "textures"));
}
if (json.variants !== undefined) {
if (!is_object(json.variants)) {
problems.push('variants must map conditions like "age=3" to { model, textures, y }');
} else {
const names = new Set(Array.isArray(json.states) ? json.states.map((s) => is_object(s) ? s.name : "") : []);
for (const [condition, variant] of Object.entries(json.variants)) {
const where = `variants["${condition}"]`;
for (const part of condition === "" ? [] : condition.split(",")) {
const [name, value] = part.split("=").map((p) => p.trim());
if (!names.has(name) || !/^\d+$/.test(value ?? "")) {
problems.push(`${where}: "${part}" must be a state of this block and a number, like age=3`);
}
}
if (!is_object(variant)) {
problems.push(`${where} must be { model, textures, y }`);
continue;
}
if (variant.model !== undefined) problems.push(...validate_id(variant.model, `${where}.model`));
if (variant.textures !== undefined) {
problems.push(...validate_textures(variant.textures, `${where}.textures`));
}
if (variant.y !== undefined && ![0, 90, 180, 270].includes(variant.y as number)) {
problems.push(`${where}.y must be 0, 90, 180 or 270`);
}
}
}
}
if (json.render_layer !== undefined && !RENDER_LAYERS.includes(json.render_layer as RenderLayer)) { if (json.render_layer !== undefined && !RENDER_LAYERS.includes(json.render_layer as RenderLayer)) {
problems.push(`render_layer must be one of ${RENDER_LAYERS.join(", ")}`); problems.push(`render_layer must be one of ${RENDER_LAYERS.join(", ")}`);
} }
@@ -294,6 +334,72 @@ export function validate_block(json: unknown): Problems {
return problems; return problems;
} }
export function validate_model(json: unknown): Problems {
if (!is_object(json)) return ["model must be an object"];
const problems = validate_id(json.id, "id");
if (json.textures !== undefined) problems.push(...validate_textures(json.textures, "textures", true));
if (!Array.isArray(json.elements) || json.elements.length === 0) {
problems.push("elements must be a list of boxes");
return problems;
}
json.elements.forEach((element, i) => {
const where = `elements[${i}]`;
if (!is_object(element)) {
problems.push(`${where} must be { from, to, faces }`);
return;
}
for (const key of ["from", "to"]) {
const point = element[key];
if (!Array.isArray(point) || point.length !== 3 || !point.every((v) => typeof v === "number")) {
problems.push(`${where}.${key} must be [x, y, z] in pixels`);
}
}
const rotation = element.rotation;
if (rotation !== undefined) {
if (
!is_object(rotation) || !["x", "y", "z"].includes(rotation.axis as string) ||
![-45, -22.5, 0, 22.5, 45].includes(rotation.angle as number) || !Array.isArray(rotation.origin)
) {
problems.push(
`${where}.rotation must be { origin, axis: x, y or z, angle: -45, -22.5, 0, 22.5 or 45 }`,
);
}
}
if (element.shade !== undefined && typeof element.shade !== "boolean") {
problems.push(`${where}.shade must be true or false`);
}
if (!is_object(element.faces)) {
problems.push(`${where}.faces must map sides to { texture }`);
return;
}
for (const [side, face] of Object.entries(element.faces)) {
if (!MODEL_FACES.includes(side as ModelFace)) {
problems.push(`${where}.faces: "${side}" isn't one of ${MODEL_FACES.join(", ")}`);
}
if (!is_object(face) || typeof face.texture !== "string") {
problems.push(`${where}.faces.${side} needs a texture, like "#side"`);
continue;
}
if (face.uv !== undefined && (!Array.isArray(face.uv) || face.uv.length !== 4)) {
problems.push(`${where}.faces.${side}.uv must be [u1, v1, u2, v2] in pixels`);
}
if (face.cullface !== undefined && !MODEL_FACES.includes(face.cullface as ModelFace)) {
problems.push(`${where}.faces.${side}.cullface isn't one of ${MODEL_FACES.join(", ")}`);
}
}
});
return problems;
}
// a texture id, or texture variables mapped to ids. a model's own defaults may also point at variables ("#side")
function validate_textures(value: unknown, field: string, allow_variables = false): Problems {
if (typeof value === "string") return validate_id(value, field);
if (!is_object(value)) return [`${field} must be a texture id or { variable: texture id }`];
return Object.entries(value).flatMap(([name, id]) =>
allow_variables && typeof id === "string" && id.startsWith("#") ? [] : validate_id(id, `${field}.${name}`)
);
}
export function validate_item(json: unknown): Problems { export function validate_item(json: unknown): Problems {
if (!is_object(json)) return ["item must be an object"]; if (!is_object(json)) return ["item must be an object"];
const problems = validate_id(json.id, "id"); const problems = validate_id(json.id, "id");
+6
View File
@@ -11,10 +11,13 @@ import {
RecipeJson, RecipeJson,
} from "./mod_data.ts"; } from "./mod_data.ts";
import { register_block_item } from "./utils.ts"; import { register_block_item } from "./utils.ts";
import type { ModelJson } from "./block_models.ts";
// everything a mod's json files hold, merged into one file by the build (build/mods/<id>/<hash>/data.json) // everything a mod's json files hold, merged into one file by the build (build/mods/<id>/<hash>/data.json)
export interface ModData { export interface ModData {
blocks: BlockJson[]; blocks: BlockJson[];
// block models, see common/block_models.ts
models?: ModelJson[];
items: ItemJson[]; items: ItemJson[];
recipes: RecipeJson[]; recipes: RecipeJson[];
ores: OreJson[]; ores: OreJson[];
@@ -66,6 +69,9 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe
throw new ModLoadError(id, message); throw new ModLoadError(id, message);
}; };
try { try {
for (const model of data.models ?? []) {
EverythingRegistry.register<ModelJson>("models", model.id, model);
}
for (const json of data.blocks) { for (const json of data.blocks) {
const { block, has_item } = block_from_json(json); const { block, has_item } = block_from_json(json);
EverythingRegistry.register<BlockRegistry>("blocks", block.id, block); EverythingRegistry.register<BlockRegistry>("blocks", block.id, block);
+3 -5
View File
@@ -1,5 +1,6 @@
import { ID_MASK, STATE_SHIFT } from "./constants.ts"; import { ID_MASK, STATE_SHIFT } from "./constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
import { block_item_texture, cube_face_texture } from "./block_models.ts";
export function point_inside_rec( export function point_inside_rec(
point_x: number, point_x: number,
@@ -38,11 +39,8 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num
} }
export function register_block_item(block: BlockRegistry) { export function register_block_item(block: BlockRegistry) {
// TODO: handle block textures // cubes are drawn from the block's textures, anything else as a sprite
let texture_id = "engine:missing"; const texture_id = block_item_texture(block) ?? cube_face_texture(block, "side");
if (typeof block.textures === "string") {
texture_id = block.textures;
}
EverythingRegistry.register<ItemRegistry>("items", block.id, { EverythingRegistry.register<ItemRegistry>("items", block.id, {
texture_id, texture_id,
block_id: block.id, block_id: block.id,
+66
View File
@@ -0,0 +1,66 @@
{
"format_version": 1,
"block": {
"id": "bworld:wheat",
"textures": {
"crop": "bworld:wheat_stage_0"
},
"model": "engine:crop",
"variants": {
"age=0": {
"textures": {
"crop": "bworld:wheat_stage_0"
}
},
"age=1": {
"textures": {
"crop": "bworld:wheat_stage_1"
}
},
"age=2": {
"textures": {
"crop": "bworld:wheat_stage_2"
}
},
"age=3": {
"textures": {
"crop": "bworld:wheat_stage_3"
}
},
"age=4": {
"textures": {
"crop": "bworld:wheat_stage_4"
}
},
"age=5": {
"textures": {
"crop": "bworld:wheat_stage_5"
}
},
"age=6": {
"textures": {
"crop": "bworld:wheat_stage_6"
}
},
"age=7": {
"textures": {
"crop": "bworld:wheat_stage_7"
}
}
},
"render_layer": "cutout",
"collision": false,
"mining": {
"toughness": 0
},
"drops": "bworld:wheat_seeds",
"item": false,
"states": [
{
"name": "age",
"bits": 3,
"default": 0
}
]
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:bone_meal",
"texture": "bworld:bone_meal"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"format_version": 1,
"item": {
"id": "bworld:wheat",
"texture": "bworld:wheat"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"format_version": 1,
"item": {
"id": "bworld:wheat_seeds",
"texture": "bworld:wheat_seeds",
"places": "bworld:wheat"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

+32 -1
View File
@@ -1,6 +1,7 @@
import { Container, ItemStack } from "$/common/inventory.ts"; import { Container, ItemStack } from "$/common/inventory.ts";
import { ScreenLayout } from "$/common/protocol.ts"; import { ScreenLayout } from "$/common/protocol.ts";
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { get_state_value, set_state_value } from "$/common/utils.ts";
import type { RecipeBook } from "$/common/mod_loader.ts"; import type { RecipeBook } from "$/common/mod_loader.ts";
import type { GameServer } from "./game_server.ts"; import type { GameServer } from "./game_server.ts";
import type { ServerPlayer } from "./player.ts"; import type { ServerPlayer } from "./player.ts";
@@ -51,6 +52,36 @@ function hoe_into_hoed_dirt(
BLOCK_BEHAVIORS["bworld:grass"] = { on_interact: hoe_into_hoed_dirt }; BLOCK_BEHAVIORS["bworld:grass"] = { on_interact: hoe_into_hoed_dirt };
BLOCK_BEHAVIORS["bworld:dirt"] = { on_interact: hoe_into_hoed_dirt }; BLOCK_BEHAVIORS["bworld:dirt"] = { on_interact: hoe_into_hoed_dirt };
// crops
// minecraft's bone meal: a crop grows 2 to 5 stages at once, and the bone meal is used up
function grow_with_bone_meal(
game: GameServer,
block: { x: number; y: number; z: number; id: string },
player: ServerPlayer,
): boolean {
const held = player.held_item;
if (held?.type_id !== "bworld:bone_meal") {
return false;
}
const info = EverythingRegistry.get<BlockRegistry>("blocks", block.id)!;
const value = game.world.get_block_value(block.x, block.y, block.z);
const age = get_state_value(value, info, "age")!;
const max_age = 2 ** info.states!.find((s) => s.name === "age")!.bits - 1;
// fully grown, keep the bone meal
if (age >= max_age) {
return false;
}
const new_age = Math.min(age + 1, max_age);
game.set_block_state(block.x, block.y, block.z, set_state_value(value, info, "age", new_age)! >>> 16);
const slot = player.inventory.get_slot(player.selected_slot);
slot.amount = slot.amount! - 1;
return true;
}
BLOCK_BEHAVIORS["bworld:wheat"] = { on_interact: grow_with_bone_meal };
// chest // chest
const CHEST_LAYOUT: ScreenLayout = { const CHEST_LAYOUT: ScreenLayout = {
+74
View File
@@ -0,0 +1,74 @@
import { assert, assertEquals } from "@std/assert";
import { bake_model, block_variant, BUILTIN_MODELS, type ModelJson, resolve_texture } from "$/common/block_models.ts";
import { block_from_json } from "$/common/mod_data.ts";
import { set_state_value } from "$/common/utils.ts";
const cube = BUILTIN_MODELS["engine:cube"];
Deno.test("cubes resolve the old texture shapes", () => {
assertEquals(resolve_texture("#top", "a:all", cube), "a:all");
const pillar = { top: "a:top", bottom: "a:bottom", side: "a:side" };
assertEquals(["#top", "#bottom", "#front", "#side"].map((t) => resolve_texture(t, pillar, cube)), [
"a:top",
"a:bottom",
"a:side",
"a:side",
]);
const furnace = { front: "a:front", side: "a:side" };
assertEquals(["#top", "#bottom", "#front"].map((t) => resolve_texture(t, furnace, cube)), [
"a:side",
"a:side",
"a:front",
]);
assertEquals(resolve_texture("#nothing", { crop: "a:crop" }), "engine:missing");
});
Deno.test("a cube bakes into six full faces that cull against their neighbors", () => {
const quads = bake_model(cube, "a:all");
assertEquals(quads.length, 6);
assert(quads.every((q) => q.flush && q.cull === q.face && q.texture === "a:all"));
// the top face, corners and uvs like the mesher always had
assertEquals(quads[0].positions, [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0]);
assertEquals(quads[0].uvs, [0, 16, 16, 16, 16, 0, 0, 0]);
});
Deno.test("crop and cross models are planes inside the block, seen from both sides", () => {
const crop = bake_model(BUILTIN_MODELS["engine:crop"], { crop: "a:wheat" });
assertEquals(crop.length, 8);
assert(crop.every((q) => !q.flush && q.cull === -1 && !q.shade && q.texture === "a:wheat"));
assertEquals(crop[0].positions.filter((_, i) => i % 3 === 0), [0.25, 0.25, 0.25, 0.25]);
const cross = bake_model(BUILTIN_MODELS["engine:cross"], { cross: "a:flower" });
assertEquals(cross.length, 4);
// rescaled so the diagonal planes stay as wide as the block, like minecraft's (0.8 to 15.2 pixels)
for (const quad of cross) {
for (const v of quad.positions) assert(v > -1e-6 && v < 1 + 1e-6, `${v} is outside the block`);
}
const xs = cross[0].positions.filter((_, i) => i % 3 === 0).map((v) => Math.round(v * 1000) / 1000);
assertEquals(new Set(xs), new Set([0.05, 0.95]));
});
Deno.test("turning a model moves its faces and cullfaces", () => {
const model: ModelJson = {
id: "a:half",
elements: [{
from: [0, 0, 0],
to: [16, 16, 8],
faces: { north: { texture: "a:x", cullface: "north" } },
}],
};
const [north] = bake_model(model, undefined, 0);
const [east] = bake_model(model, undefined, 90);
assertEquals([north.face, north.cull], [3, 3]);
assertEquals([east.face, east.cull], [5, 5]);
assert(east.flush);
});
Deno.test("variants pick textures by state", () => {
const { block } = block_from_json(JSON.parse(Deno.readTextFileSync("mods/bworld/blocks/wheat.json")).block);
assertEquals(block_variant(block, { age: 5 }).textures, { crop: "bworld:wheat_stage_5" });
assertEquals(block_variant(block, { age: 5 }).model, "engine:crop");
assertEquals(block_variant(block).textures, { crop: "bworld:wheat_stage_0" });
// states live above the numeric id
assertEquals(set_state_value(7, block, "age", 3), (3 << 16) | 7);
});
+35 -3
View File
@@ -3,6 +3,7 @@ import { AIR, CHUNK_HEIGHT } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts"; import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts";
import { generate_raw_chunk } from "$/common/generation.ts"; import { generate_raw_chunk } from "$/common/generation.ts";
import { get_state_value } from "$/common/utils.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts"; import { load_worldgen } from "$/common/worldgen_loader.ts";
import { create_mod } from "$/tools/new_mod.ts"; import { create_mod } from "$/tools/new_mod.ts";
import { PROTOCOL_VERSION } from "$/common/protocol.ts"; import { PROTOCOL_VERSION } from "$/common/protocol.ts";
@@ -23,9 +24,9 @@ const inventory_of = (messages: Msg[]) =>
Deno.test("the base game loads from mods/bworld", async () => { Deno.test("the base game loads from mods/bworld", async () => {
const { game } = await test_game("mods"); const { game } = await test_game("mods");
assertEquals(EverythingRegistry.entries("blocks").length, 19); assertEquals(EverythingRegistry.entries("blocks").length, 20);
// 11 items plus the item forms of the 16 blocks that have one // 14 items plus the item forms of the 17 blocks that have one
assertEquals(EverythingRegistry.entries("items").length, 27); assertEquals(EverythingRegistry.entries("items").length, 31);
assertEquals(game.recipes.shaped.length, 5); assertEquals(game.recipes.shaped.length, 5);
assertEquals(game.recipes.furnace.size, 6); assertEquals(game.recipes.furnace.size, 6);
assertEquals(game.recipes.fuel.size, 2); assertEquals(game.recipes.fuel.size, 2);
@@ -34,6 +35,37 @@ Deno.test("the base game loads from mods/bworld", async () => {
assert(EverythingRegistry.get<ItemRegistry>("items", "bworld:watering_can")?.on_create); assert(EverythingRegistry.get<ItemRegistry>("items", "bworld:watering_can")?.on_create);
}); });
Deno.test("bone meal grows wheat until it's fully grown", async () => {
const { game, take, send } = await test_game("mods");
game.on_connect(1);
send(1, { type: "hello", name: "alice", protocol: PROTOCOL_VERSION });
send(1, { type: "ready" });
let y = CHUNK_HEIGHT - 1;
while (game.world.get_block_nid(1, y, 1) === AIR) y--;
y += 1;
game.set_block(1, y, 1, "bworld:wheat");
const wheat = EverythingRegistry.get<BlockRegistry>("blocks", "bworld:wheat")!;
const age = () => get_state_value(game.world.get_block_value(1, y, 1), wheat, "age")!;
assertEquals(age(), 0);
send(1, { type: "chat", text: "/give bworld:bone_meal 8" });
const slot = inventory_of(take(1)).findIndex((i: Msg) => i?.id === "bworld:bone_meal");
send(1, { type: "select_slot", slot });
send(1, { type: "move", x: 2.5, y, z: 2.5, yaw: 0, pitch: 0 });
// 2 to 5 stages at a time
send(1, { type: "use_block", x: 1, y, z: 1, face: "top" });
assert(age() >= 2 && age() <= 5, `age ${age()}`);
// at most 3 more uses to reach 7, then it stops using bone meal
for (let i = 0; i < 5; i++) send(1, { type: "use_block", x: 1, y, z: 1, face: "top" });
assertEquals(age(), 7);
const left = inventory_of(take(1))[slot]?.count;
assert(left >= 4 && left <= 6, `${left} bone meal left`);
// nothing got placed on top
assertEquals(game.world.get_block_id(1, y + 1, 1), "bworld:air");
});
Deno.test("mods/bworld json survives going to the registry and back", async () => { Deno.test("mods/bworld json survives going to the registry and back", async () => {
await test_game("mods"); await test_game("mods");
for (const file of Deno.readDirSync("mods/bworld/blocks")) { for (const file of Deno.readDirSync("mods/bworld/blocks")) {
+18
View File
@@ -65,6 +65,20 @@ Deno.test("check-mods finds broken mods", async () => {
format_version: 1, format_version: 1,
block: { id: "broken:x", textures: "bworld:stone" }, block: { id: "broken:x", textures: "bworld:stone" },
}); });
// a model that doesn't exist, a variant for a state the block doesn't have, and a broken model
write_json(`${mod}/blocks/plant.json`, {
format_version: 1,
block: { id: "broken:plant", textures: { cross: "broken:plant" }, model: "broken:no_such_model" },
});
write_json(`${mod}/blocks/grows.json`, {
format_version: 1,
block: { id: "broken:grows", textures: "broken:plant", variants: { "size=1": { y: 90 } } },
});
Deno.mkdirSync(`${mod}/models`);
write_json(`${mod}/models/bad.json`, {
format_version: 1,
model: { id: "broken:bad", elements: [{ from: [0, 0], to: [16, 16, 16], faces: { up: { texture: "#all" } } }] },
});
// not json // not json
Deno.writeTextFileSync(`${mod}/items/bad.json`, "{ nope"); Deno.writeTextFileSync(`${mod}/items/bad.json`, "{ nope");
// uses a letter the key doesn't define // uses a letter the key doesn't define
@@ -93,6 +107,10 @@ Deno.test("check-mods finds broken mods", async () => {
has(report.errors, "item broken:no_such_item doesn't exist"); 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, "block broken:x is also defined by broken, broken");
has(report.errors, "items/bad.json: isn't valid json"); has(report.errors, "items/bad.json: isn't valid json");
has(report.errors, "model broken:no_such_model doesn't exist");
has(report.errors, `"size=1" must be a state of this block`);
has(report.errors, "elements[0].from must be [x, y, z]");
has(report.errors, `elements[0].faces: "up" isn't one of`);
has(report.errors, `pattern uses "B" but key doesn't define it`); has(report.errors, `pattern uses "B" but key doesn't define it`);
has(report.errors, "textures/huge.png: is"); has(report.errors, "textures/huge.png: is");
has(report.errors, "scripts don't typecheck"); has(report.errors, "scripts don't typecheck");
+32 -5
View File
@@ -9,9 +9,11 @@ import {
validate_block, validate_block,
validate_item, validate_item,
validate_manifest, validate_manifest,
validate_model,
validate_ore, validate_ore,
validate_recipe, validate_recipe,
} from "$/common/mod_data.ts"; } from "$/common/mod_data.ts";
import { BUILTIN_MODELS, type ModelJson } from "$/common/block_models.ts";
export interface ModReport { export interface ModReport {
id: string; id: string;
@@ -25,6 +27,7 @@ export interface LoadedMod {
report: ModReport; report: ModReport;
manifest?: Record<string, unknown>; manifest?: Record<string, unknown>;
blocks: { file: string; json: BlockJson }[]; blocks: { file: string; json: BlockJson }[];
models: { file: string; json: ModelJson }[];
items: { file: string; json: ItemJson }[]; items: { file: string; json: ItemJson }[];
recipes: { file: string; json: RecipeJson }[]; recipes: { file: string; json: RecipeJson }[];
ores: { file: string; json: OreJson }[]; ores: { file: string; json: OreJson }[];
@@ -99,6 +102,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
dir, dir,
report: { id, errors: [], warnings: [] }, report: { id, errors: [], warnings: [] },
blocks: [], blocks: [],
models: [],
items: [], items: [],
recipes: [], recipes: [],
ores: [], ores: [],
@@ -147,6 +151,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
} }
}; };
load_data("blocks", "block", validate_block, mod.blocks); load_data("blocks", "block", validate_block, mod.blocks);
load_data("models", "model", validate_model, mod.models);
load_data("items", "item", validate_item, mod.items); load_data("items", "item", validate_item, mod.items);
load_data("recipes", "recipe", validate_recipe, mod.recipes); load_data("recipes", "recipe", validate_recipe, mod.recipes);
@@ -164,7 +169,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
} }
// a mod only registers ids in its own namespace // a mod only registers ids in its own namespace
for (const { file, json } of [...mod.blocks, ...mod.items]) { for (const { file, json } of [...mod.blocks, ...mod.models, ...mod.items]) {
if (json.id.split(":")[0] !== id) error(`${file}: ${json.id} isn't in this mod's namespace "${id}"`); if (json.id.split(":")[0] !== id) error(`${file}: ${json.id} isn't in this mod's namespace "${id}"`);
} }
@@ -186,6 +191,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
function check_references(mods: LoadedMod[]) { function check_references(mods: LoadedMod[]) {
const block_owners = new Map<string, string[]>(); const block_owners = new Map<string, string[]>();
const item_owners = new Map<string, string[]>(); const item_owners = new Map<string, string[]>();
const model_owners = new Map<string, string[]>();
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]); const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
const textures = new Set<string>(); const textures = new Set<string>();
@@ -199,6 +205,7 @@ function check_references(mods: LoadedMod[]) {
if (json.item !== false) add(item_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 { json } of mod.items) add(item_owners, json.id, mod.id);
for (const { json } of mod.models) add(model_owners, json.id, mod.id);
for (const texture of mod.textures) textures.add(texture); for (const texture of mod.textures) textures.add(texture);
} }
@@ -213,12 +220,14 @@ function check_references(mods: LoadedMod[]) {
if (!mod_ids.has(dep)) errors.push(`manifest.json: depends on "${dep}", which isn't installed`); 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 uses = (file: string, id: string, what: "block" | "item" | "texture" | "model") => {
const namespace = id.split(":")[0]; const namespace = id.split(":")[0];
if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) { if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) {
warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`); warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`);
} }
if (what === "texture") { if (what === "model") {
if (!BUILTIN_MODELS[id] && !model_owners.has(id)) errors.push(`${file}: model ${id} doesn't exist`);
} else if (what === "texture") {
if (!textures.has(id)) warnings.push(`${file}: texture ${id} doesn't exist, it will show as missing`); 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)) { } else if (!(what === "block" ? block_owners : item_owners).has(id)) {
errors.push(`${file}: ${what} ${id} doesn't exist`); errors.push(`${file}: ${what} ${id} doesn't exist`);
@@ -229,10 +238,28 @@ function check_references(mods: LoadedMod[]) {
if ((block_owners.get(json.id)?.length ?? 0) > 1) { 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(", ")}`); 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 variant of [json, ...Object.values(json.variants ?? {})]) {
for (const texture of texture_ids) uses(file, texture, "texture"); if (variant.model) uses(file, variant.model, "model");
const textures = variant.textures ?? {};
for (const texture of typeof textures === "string" ? [textures] : Object.values(textures)) {
uses(file, texture, "texture");
}
}
if (json.drops) uses(file, json.drops, "item"); if (json.drops) uses(file, json.drops, "item");
} }
for (const { file, json } of mod.models) {
if ((model_owners.get(json.id)?.length ?? 0) > 1 || BUILTIN_MODELS[json.id]) {
errors.push(`${file}: model ${json.id} is defined more than once`);
}
for (const texture of Object.values(json.textures ?? {})) {
if (!texture.startsWith("#")) uses(file, texture, "texture");
}
for (const element of json.elements) {
for (const face of Object.values(element.faces)) {
if (face && !face.texture.startsWith("#")) uses(file, face.texture, "texture");
}
}
}
for (const { file, json } of mod.items) { for (const { file, json } of mod.items) {
if ((item_owners.get(json.id)?.length ?? 0) > 1) { 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(", ")}`); errors.push(`${file}: item ${json.id} is also defined by ${item_owners.get(json.id)!.join(", ")}`);