Basic crops system
This commit is contained in:
@@ -20,6 +20,7 @@ too, since the client is already a web page.
|
||||
- [Identifiers](#identifiers)
|
||||
- [Textures](#textures)
|
||||
- [Blocks](#blocks)
|
||||
- [Block models](#block-models)
|
||||
- [Items](#items)
|
||||
- [Recipes](#recipes)
|
||||
- [Server scripts](#server-scripts)
|
||||
@@ -89,6 +90,7 @@ mods/
|
||||
copper_tools/
|
||||
manifest.json
|
||||
blocks/*.json
|
||||
models/*.json
|
||||
items/*.json
|
||||
recipes/*.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 |
|
||||
| ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `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). |
|
||||
@@ -216,8 +220,84 @@ functions.
|
||||
|
||||
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
|
||||
`ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. They can't change textures
|
||||
yet, because the mesher ignores them. `variants` is reserved for that.
|
||||
`ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. `variants` changes how the
|
||||
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
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
|
||||
|
||||
const data: ModData = {
|
||||
blocks: mod.blocks.map((b) => b.json),
|
||||
models: mod.models.map((m) => m.json),
|
||||
items: mod.items.map((i) => i.json),
|
||||
recipes: mod.recipes.map((r) => r.json),
|
||||
ores: mod.ores.map((o) => o.json),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } fr
|
||||
import { crosses_planes } from "../workers/translucent_sort.ts";
|
||||
import { Camera } from "../camera.ts";
|
||||
import type { Entity } from "../entity/entity.ts";
|
||||
import type { ModelJson } from "$/common/block_models.ts";
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
@@ -111,6 +112,7 @@ export class ClientLevel {
|
||||
type: "init",
|
||||
blocks_registry: strip_functions(blocks_registry),
|
||||
block_ids,
|
||||
models: Object.fromEntries(EverythingRegistry.entries<ModelJson>("models")),
|
||||
textures_info: AssetManager.instance.get("bworld:textures_info"),
|
||||
image: { width: this.image.width, height: this.image.height },
|
||||
worldgen_scripts: worldgen_mods.scripts,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AssetManager } from "$/client/assets.ts";
|
||||
import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import type { ItemEntity } from "$/client/entity/item_entity.ts";
|
||||
import { push_vertex, Texture } from "$/client/renderer/mod.ts";
|
||||
import { block_item_texture, cube_face_texture } from "$/common/block_models.ts";
|
||||
|
||||
// how big items on the ground are drawn, minecraft's ground transform
|
||||
const BLOCK_SCALE = 0.25;
|
||||
@@ -40,9 +41,11 @@ function copies(amount: number) {
|
||||
export function render_item_entity(entity: ItemEntity, partial_tick: number) {
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id);
|
||||
const block_info = item_info?.block_id
|
||||
// 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)
|
||||
: undefined;
|
||||
if (block_info && block_item_texture(block_info) !== undefined) block_info = undefined;
|
||||
|
||||
// spinning and bobbing, like minecraft's ItemEntityRenderer
|
||||
const time = entity.age + partial_tick;
|
||||
@@ -79,7 +82,7 @@ function push_block(
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
const texture_id = item_info?.texture_id;
|
||||
if (typeof texture_id === "function") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { get_sprite_region } from "$/client/sprites.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { AssetManager } from "$/client/assets.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";
|
||||
|
||||
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}'`);
|
||||
}
|
||||
|
||||
if (item_info?.block_id) {
|
||||
draw_item_block(item, item_info, x, y);
|
||||
const block_info = item_info.block_id
|
||||
? 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 {
|
||||
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) {
|
||||
const block_info = EverythingRegistry.get<BlockRegistry>("blocks", item_info.block_id!);
|
||||
|
||||
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`);
|
||||
|
||||
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;
|
||||
}
|
||||
function draw_item_block(block_info: BlockRegistry, x: number, y: number) {
|
||||
const top_texture = cube_face_texture(block_info, "top");
|
||||
const front_texture = cube_face_texture(block_info, "front");
|
||||
const left_texture = cube_face_texture(block_info, "side");
|
||||
|
||||
const atlas = AssetManager.instance.get<Texture>("bworld:textures");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BlockRegistry } from "$/common/everything_registry.ts";
|
||||
import type { SpriteRegion } from "$/common/constants.ts";
|
||||
import type { OreJson } from "$/common/mod_data.ts";
|
||||
import type { ModelJson } from "$/common/block_models.ts";
|
||||
import type { SortType } from "./translucent_sort.ts";
|
||||
|
||||
// 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
|
||||
blocks_registry: BlockRegistry[];
|
||||
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>;
|
||||
image: { width: number; height: number };
|
||||
// mods' worldgen scripts and ores, so generation matches the server
|
||||
|
||||
+164
-100
@@ -6,12 +6,21 @@ import {
|
||||
type BlockRegistry,
|
||||
type RenderLayer,
|
||||
} from "$/common/everything_registry.ts";
|
||||
import { AIR, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK, type SpriteRegion, TEXTURE_SIZE } from "$/common/constants.ts";
|
||||
import {
|
||||
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 FromChunkWorker, TERRAIN_VERTEX_FLOATS, type ToChunkWorker } from "./chunk_messages.ts";
|
||||
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
|
||||
import { load_worldgen } from "$/common/worldgen_loader.ts";
|
||||
import { default_block_value } from "$/common/utils.ts";
|
||||
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 {
|
||||
choose_sort_type,
|
||||
FACE_NORMALS,
|
||||
@@ -37,19 +46,7 @@ const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
|
||||
// keeps texture lookups off the sprite's edge
|
||||
const UV_PAD = 0.5;
|
||||
|
||||
// same order as FACE_NORMALS in translucent_sort.ts
|
||||
const FACES = ["top", "bottom", "front", "back", "left", "right"] as const;
|
||||
// each face's corners in drawing order (counter clockwise from outside), as offsets from the block's corner
|
||||
const FACE_CORNERS = [
|
||||
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
|
||||
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
|
||||
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
|
||||
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
|
||||
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
|
||||
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
|
||||
] as const;
|
||||
// which end of the sprite each corner gets, u then v (0 = start, 1 = end)
|
||||
const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
|
||||
// same order as FACE_NORMALS in translucent_sort.ts, and FACE_CORNERS and CORNER_UVS in block_models.ts
|
||||
// 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];
|
||||
|
||||
@@ -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 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 image: Texture;
|
||||
let worldgen: WorldgenSetup | undefined;
|
||||
@@ -100,6 +115,8 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
|
||||
case "init":
|
||||
blocks_registry = message.blocks_registry;
|
||||
block_ids = message.block_ids;
|
||||
models = message.models;
|
||||
baked.clear();
|
||||
build_block_tables();
|
||||
textures_info = message.textures_info;
|
||||
image = message.image as Texture;
|
||||
@@ -115,7 +132,12 @@ self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
|
||||
case "mesh": {
|
||||
region.fill(message.chunks);
|
||||
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(
|
||||
{
|
||||
type: "meshed",
|
||||
@@ -263,34 +285,19 @@ function should_flip() {
|
||||
return light(0) + light(2) > light(1) + light(3);
|
||||
}
|
||||
|
||||
function push_quad(
|
||||
vertices: Float32Array,
|
||||
i: number,
|
||||
face: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
sprite: SpriteRegion,
|
||||
alpha: number,
|
||||
) {
|
||||
const u0 = (sprite.x * TEXTURE_SIZE + UV_PAD) / image.width;
|
||||
const v0 = (sprite.y * TEXTURE_SIZE + UV_PAD) / image.height;
|
||||
const u1 = ((sprite.x + 1) * TEXTURE_SIZE - UV_PAD) / image.width;
|
||||
const v1 = ((sprite.y + 1) * TEXTURE_SIZE - UV_PAD) / image.height;
|
||||
const shade = FACE_SHADE[face];
|
||||
function push_quad(vertices: Float32Array, i: number, quad: MeshQuad, x: number, y: number, z: number, alpha: number) {
|
||||
const sprite = quad.sprite;
|
||||
// starting from the second corner moves the diagonal, the winding stays the same
|
||||
const first = should_flip() ? 1 : 0;
|
||||
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const corner = (first + k) & 3;
|
||||
const [cx, cy, cz] = FACE_CORNERS[face][corner];
|
||||
const [cu, cv] = CORNER_UVS[corner];
|
||||
const brightness = shade * corner_ao[corner];
|
||||
vertices[i++] = x + cx;
|
||||
vertices[i++] = y + cy;
|
||||
vertices[i++] = z + cz;
|
||||
vertices[i++] = cu ? u1 : u0;
|
||||
vertices[i++] = cv ? v1 : v0;
|
||||
const brightness = quad.shade * corner_ao[corner];
|
||||
vertices[i++] = x + quad.positions[corner * 3];
|
||||
vertices[i++] = y + quad.positions[corner * 3 + 1];
|
||||
vertices[i++] = z + quad.positions[corner * 3 + 2];
|
||||
vertices[i++] = atlas_u(sprite, quad.uvs[corner * 2]);
|
||||
vertices[i++] = atlas_v(sprite, quad.uvs[corner * 2 + 1]);
|
||||
vertices[i++] = brightness;
|
||||
vertices[i++] = brightness;
|
||||
vertices[i++] = brightness;
|
||||
@@ -302,14 +309,111 @@ function push_quad(
|
||||
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
|
||||
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 }));
|
||||
// for sorting the translucent quads
|
||||
let centers = new Float32Array(256);
|
||||
let faces = new Uint8Array(256);
|
||||
|
||||
// where the neighbor on each face is, same order as FACES
|
||||
// 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);
|
||||
|
||||
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 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 wz = chunk_z * CHUNK_SIZE + z;
|
||||
|
||||
for (let face = 0; face < 6; face++) {
|
||||
const front = index + face_offsets[face];
|
||||
const front_y = y + FACE_NORMALS[face][1];
|
||||
if (!show_face(block_nid, region_block(region, front, front_y))) {
|
||||
continue;
|
||||
for (const quad of block_quads(values[y * CHUNK_AREA + z * CHUNK_SIZE + x])) {
|
||||
if (quad.cull >= 0) {
|
||||
const neighbor = region_block(
|
||||
region,
|
||||
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.floats = push_quad(
|
||||
layer.vertices,
|
||||
layer.floats,
|
||||
face,
|
||||
wx,
|
||||
y,
|
||||
wz,
|
||||
textures_info[texture_ids[FACES[face]]],
|
||||
alpha,
|
||||
);
|
||||
layer.floats = push_quad(layer.vertices, layer.floats, quad, wx, y, wz, alpha);
|
||||
|
||||
if (layer_id === TRANSLUCENT) {
|
||||
const quad = layer.floats / FLOATS_PER_QUAD - 1;
|
||||
centers = ensure_capacity(centers, (quad + 1) * 3);
|
||||
faces = ensure_capacity(faces, quad + 1);
|
||||
const [nx, ny, nz] = FACE_NORMALS[face];
|
||||
centers[quad * 3] = wx + 0.5 + nx * 0.5;
|
||||
centers[quad * 3 + 1] = y + 0.5 + ny * 0.5;
|
||||
centers[quad * 3 + 2] = wz + 0.5 + nz * 0.5;
|
||||
faces[quad] = face;
|
||||
// sorting treats every quad as facing along an axis, rotated ones too
|
||||
const q = layer.floats / FLOATS_PER_QUAD - 1;
|
||||
centers = ensure_capacity(centers, (q + 1) * 3);
|
||||
faces = ensure_capacity(faces, q + 1);
|
||||
const p = quad.positions;
|
||||
centers[q * 3] = wx + (p[0] + p[6]) / 2;
|
||||
centers[q * 3 + 1] = y + (p[1] + p[7]) / 2;
|
||||
centers[q * 3 + 2] = wz + (p[2] + p[8]) / 2;
|
||||
faces[q] = quad.face;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ItemStack } from "./inventory.ts";
|
||||
import type { BlockVariant } from "./block_models.ts";
|
||||
|
||||
export class EverythingRegistry {
|
||||
static #key_to_id = new Map<string, Map<string, number>>();
|
||||
@@ -65,16 +66,9 @@ export class EverythingRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
interface TextureSideTopBottom {
|
||||
top: string;
|
||||
bottom: string;
|
||||
side: string;
|
||||
}
|
||||
|
||||
interface TextureFront {
|
||||
front: string;
|
||||
side: string;
|
||||
}
|
||||
// one texture for everything, or the model's texture variables: { top, bottom, side } or { front, side } for
|
||||
// cubes, { crop } for engine:crop and so on
|
||||
export type BlockTextures = string | Record<string, string>;
|
||||
|
||||
export interface BlockStateDefinition {
|
||||
name: string;
|
||||
@@ -88,11 +82,6 @@ interface CompiledStateDefinition {
|
||||
shift: number;
|
||||
}
|
||||
|
||||
interface BlockStateVariant {
|
||||
model: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// solid: fully opaque. cutout: texels are either opaque or see-through (leaves).
|
||||
// translucent: blended and sorted back to front (water, glass)
|
||||
export const RENDER_LAYERS = ["solid", "cutout", "translucent"] as const;
|
||||
@@ -110,7 +99,9 @@ export function block_light_emission(block: BlockRegistry | undefined): number {
|
||||
|
||||
export interface BlockRegistry {
|
||||
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
|
||||
render_layer?: RenderLayer;
|
||||
// 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;
|
||||
|
||||
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.
|
||||
// behavior runs on the server, see server/game/blocks.ts
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RENDER_LAYERS,
|
||||
type RenderLayer,
|
||||
} from "./everything_registry.ts";
|
||||
import { type BlockVariant, MODEL_FACES, type ModelFace } from "./block_models.ts";
|
||||
|
||||
export const FORMAT_VERSION = 1;
|
||||
export const ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
|
||||
@@ -31,6 +32,8 @@ export interface ManifestJson {
|
||||
export interface BlockJson {
|
||||
id: string;
|
||||
textures: BlockTextures;
|
||||
model?: string;
|
||||
variants?: Record<string, BlockVariant>;
|
||||
render_layer?: RenderLayer;
|
||||
cull_same?: boolean;
|
||||
light_emission?: number;
|
||||
@@ -89,6 +92,8 @@ export interface GridRecipe {
|
||||
|
||||
export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJson {
|
||||
const json: BlockJson = { id: block.id, textures: block.textures };
|
||||
if (block.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.cull_same !== undefined) json.cull_same = block.cull_same;
|
||||
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,
|
||||
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);
|
||||
if (render_layer && render_layer !== "solid") block.render_layer = render_layer;
|
||||
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 {
|
||||
if (!is_object(json)) return ["block must be an object"];
|
||||
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;
|
||||
if (is_cube) {
|
||||
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 }");
|
||||
} 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)) {
|
||||
problems.push(`render_layer must be one of ${RENDER_LAYERS.join(", ")}`);
|
||||
}
|
||||
@@ -294,6 +334,72 @@ export function validate_block(json: unknown): 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 {
|
||||
if (!is_object(json)) return ["item must be an object"];
|
||||
const problems = validate_id(json.id, "id");
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
RecipeJson,
|
||||
} from "./mod_data.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)
|
||||
export interface ModData {
|
||||
blocks: BlockJson[];
|
||||
// block models, see common/block_models.ts
|
||||
models?: ModelJson[];
|
||||
items: ItemJson[];
|
||||
recipes: RecipeJson[];
|
||||
ores: OreJson[];
|
||||
@@ -66,6 +69,9 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe
|
||||
throw new ModLoadError(id, message);
|
||||
};
|
||||
try {
|
||||
for (const model of data.models ?? []) {
|
||||
EverythingRegistry.register<ModelJson>("models", model.id, model);
|
||||
}
|
||||
for (const json of data.blocks) {
|
||||
const { block, has_item } = block_from_json(json);
|
||||
EverythingRegistry.register<BlockRegistry>("blocks", block.id, block);
|
||||
|
||||
+3
-5
@@ -1,5 +1,6 @@
|
||||
import { ID_MASK, STATE_SHIFT } from "./constants.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(
|
||||
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) {
|
||||
// TODO: handle block textures
|
||||
let texture_id = "engine:missing";
|
||||
if (typeof block.textures === "string") {
|
||||
texture_id = block.textures;
|
||||
}
|
||||
// cubes are drawn from the block's textures, anything else as a sprite
|
||||
const texture_id = block_item_texture(block) ?? cube_face_texture(block, "side");
|
||||
EverythingRegistry.register<ItemRegistry>("items", block.id, {
|
||||
texture_id,
|
||||
block_id: block.id,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:bone_meal",
|
||||
"texture": "bworld:bone_meal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:wheat",
|
||||
"texture": "bworld:wheat"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import { Container, ItemStack } from "$/common/inventory.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 { GameServer } from "./game_server.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: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
|
||||
|
||||
const CHEST_LAYOUT: ScreenLayout = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { AIR, CHUNK_HEIGHT } from "$/common/constants.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 { generate_raw_chunk } from "$/common/generation.ts";
|
||||
import { get_state_value } from "$/common/utils.ts";
|
||||
import { load_worldgen } from "$/common/worldgen_loader.ts";
|
||||
import { create_mod } from "$/tools/new_mod.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 () => {
|
||||
const { game } = await test_game("mods");
|
||||
assertEquals(EverythingRegistry.entries("blocks").length, 19);
|
||||
// 11 items plus the item forms of the 16 blocks that have one
|
||||
assertEquals(EverythingRegistry.entries("items").length, 27);
|
||||
assertEquals(EverythingRegistry.entries("blocks").length, 20);
|
||||
// 14 items plus the item forms of the 17 blocks that have one
|
||||
assertEquals(EverythingRegistry.entries("items").length, 31);
|
||||
assertEquals(game.recipes.shaped.length, 5);
|
||||
assertEquals(game.recipes.furnace.size, 6);
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await test_game("mods");
|
||||
for (const file of Deno.readDirSync("mods/bworld/blocks")) {
|
||||
|
||||
@@ -65,6 +65,20 @@ Deno.test("check-mods finds broken mods", async () => {
|
||||
format_version: 1,
|
||||
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
|
||||
Deno.writeTextFileSync(`${mod}/items/bad.json`, "{ nope");
|
||||
// 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, "block broken:x is also defined by broken, broken");
|
||||
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, "textures/huge.png: is");
|
||||
has(report.errors, "scripts don't typecheck");
|
||||
|
||||
+32
-5
@@ -9,9 +9,11 @@ import {
|
||||
validate_block,
|
||||
validate_item,
|
||||
validate_manifest,
|
||||
validate_model,
|
||||
validate_ore,
|
||||
validate_recipe,
|
||||
} from "$/common/mod_data.ts";
|
||||
import { BUILTIN_MODELS, type ModelJson } from "$/common/block_models.ts";
|
||||
|
||||
export interface ModReport {
|
||||
id: string;
|
||||
@@ -25,6 +27,7 @@ export interface LoadedMod {
|
||||
report: ModReport;
|
||||
manifest?: Record<string, unknown>;
|
||||
blocks: { file: string; json: BlockJson }[];
|
||||
models: { file: string; json: ModelJson }[];
|
||||
items: { file: string; json: ItemJson }[];
|
||||
recipes: { file: string; json: RecipeJson }[];
|
||||
ores: { file: string; json: OreJson }[];
|
||||
@@ -99,6 +102,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
|
||||
dir,
|
||||
report: { id, errors: [], warnings: [] },
|
||||
blocks: [],
|
||||
models: [],
|
||||
items: [],
|
||||
recipes: [],
|
||||
ores: [],
|
||||
@@ -147,6 +151,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
|
||||
}
|
||||
};
|
||||
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("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
|
||||
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}"`);
|
||||
}
|
||||
|
||||
@@ -186,6 +191,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
|
||||
function check_references(mods: LoadedMod[]) {
|
||||
const block_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 textures = new Set<string>();
|
||||
@@ -199,6 +205,7 @@ function check_references(mods: LoadedMod[]) {
|
||||
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.models) add(model_owners, json.id, mod.id);
|
||||
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`);
|
||||
}
|
||||
|
||||
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];
|
||||
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 (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`);
|
||||
} else if (!(what === "block" ? block_owners : item_owners).has(id)) {
|
||||
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) {
|
||||
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");
|
||||
for (const variant of [json, ...Object.values(json.variants ?? {})]) {
|
||||
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");
|
||||
}
|
||||
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) {
|
||||
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(", ")}`);
|
||||
|
||||
Reference in New Issue
Block a user