From 099811670f0824a6a8147dd2f18ef1289fced46e Mon Sep 17 00:00:00 2001 From: Paula Date: Fri, 25 Sep 2026 22:49:27 -0300 Subject: [PATCH] Basic crops system --- MODS.md | 86 +++++++- build.ts | 1 + client/level/client_level.ts | 2 + client/rendering/item_renderer.ts | 18 +- client/rendering/render_utils.ts | 37 ++-- client/workers/chunk_messages.ts | 3 + client/workers/chunk_worker.ts | 264 +++++++++++++++---------- common/block_models.ts | 308 +++++++++++++++++++++++++++++ common/everything_registry.ts | 26 +-- common/mod_data.ts | 116 ++++++++++- common/mod_loader.ts | 6 + common/utils.ts | 8 +- mods/bworld/blocks/wheat.json | 66 +++++++ mods/bworld/items/bone_meal.json | 7 + mods/bworld/items/wheat.json | 7 + mods/bworld/items/wheat_seeds.json | 8 + mods/bworld/textures/bone_meal.png | Bin 0 -> 165 bytes server/game/blocks.ts | 33 +++- tests/block_models_test.ts | 74 +++++++ tests/mod_loading_test.ts | 38 +++- tests/mod_tools_test.ts | 18 ++ tools/check_mods.ts | 37 +++- 22 files changed, 985 insertions(+), 178 deletions(-) create mode 100644 common/block_models.ts create mode 100644 mods/bworld/blocks/wheat.json create mode 100644 mods/bworld/items/bone_meal.json create mode 100644 mods/bworld/items/wheat.json create mode 100644 mods/bworld/items/wheat_seeds.json create mode 100644 mods/bworld/textures/bone_meal.png create mode 100644 tests/block_models_test.ts diff --git a/MODS.md b/MODS.md index e13dc0f..1a2038d 100644 --- a/MODS.md +++ b/MODS.md @@ -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 diff --git a/build.ts b/build.ts index 49f249b..3603f70 100644 --- a/build.ts +++ b/build.ts @@ -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), diff --git a/client/level/client_level.ts b/client/level/client_level.ts index 8f50a6d..bd27cc5 100644 --- a/client/level/client_level.ts +++ b/client/level/client_level.ts @@ -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("models")), textures_info: AssetManager.instance.get("bworld:textures_info"), image: { width: this.image.width, height: this.image.height }, worldgen_scripts: worldgen_mods.scripts, diff --git a/client/rendering/item_renderer.ts b/client/rendering/item_renderer.ts index e756997..ed2358f 100644 --- a/client/rendering/item_renderer.ts +++ b/client/rendering/item_renderer.ts @@ -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("bworld:textures"); const item_info = EverythingRegistry.get("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("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") { diff --git a/client/rendering/render_utils.ts b/client/rendering/render_utils.ts index 08dcdbd..1d096fb 100644 --- a/client/rendering/render_utils.ts +++ b/client/rendering/render_utils.ts @@ -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("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("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("bworld:textures"); diff --git a/client/workers/chunk_messages.ts b/client/workers/chunk_messages.ts index 1c75195..032ee67 100644 --- a/client/workers/chunk_messages.ts +++ b/client/workers/chunk_messages.ts @@ -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; + // mods' block models by id, the engine's are built in + models: Record; textures_info: Record; image: { width: number; height: number }; // mods' worldgen scripts and ores, so generation matches the server diff --git a/client/workers/chunk_worker.ts b/client/workers/chunk_worker.ts index cfa86af..519fc38 100644 --- a/client/workers/chunk_worker.ts +++ b/client/workers/chunk_worker.ts @@ -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 = {}; +// by block value, or by numeric id for blocks without variants +const baked = new Map(); + let textures_info: TexturesInfo = {}; let image: Texture; let worldgen: WorldgenSetup | undefined; @@ -100,6 +115,8 @@ self.onmessage = async (event: MessageEvent) => { 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) => { 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 | 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; } } } diff --git a/common/block_models.ts b/common/block_models.ts new file mode 100644 index 0000000..d92d7a2 --- /dev/null +++ b/common/block_models.ts @@ -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>; +} + +export interface ModelJson { + id: string; + // defaults for texture variables, can point at other variables ("top": "#side") + textures?: Record; + 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 = { + // 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 + >; +} + +export function find_model(id: string, models: Record): 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 | 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): Required { + const variant: Required = { + 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) { + 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 = { 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 }; +} diff --git a/common/everything_registry.ts b/common/everything_registry.ts index 3f496b1..1ed1cfa 100644 --- a/common/everything_registry.ts +++ b/common/everything_registry.ts @@ -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>(); @@ -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; 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; + // a different model or textures for some states, by conditions like "age=7". the first match wins + variants?: Record; // 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 diff --git a/common/mod_data.ts b/common/mod_data.ts index 1884de6..a38fa62 100644 --- a/common/mod_data.ts +++ b/common/mod_data.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; 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; - 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 }"); + 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"); diff --git a/common/mod_loader.ts b/common/mod_loader.ts index 5ed04cb..7502ab3 100644 --- a/common/mod_loader.ts +++ b/common/mod_loader.ts @@ -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///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("models", model.id, model); + } for (const json of data.blocks) { const { block, has_item } = block_from_json(json); EverythingRegistry.register("blocks", block.id, block); diff --git a/common/utils.ts b/common/utils.ts index f49d043..80da72c 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -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("items", block.id, { texture_id, block_id: block.id, diff --git a/mods/bworld/blocks/wheat.json b/mods/bworld/blocks/wheat.json new file mode 100644 index 0000000..efc6524 --- /dev/null +++ b/mods/bworld/blocks/wheat.json @@ -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 + } + ] + } +} diff --git a/mods/bworld/items/bone_meal.json b/mods/bworld/items/bone_meal.json new file mode 100644 index 0000000..f6f4578 --- /dev/null +++ b/mods/bworld/items/bone_meal.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:bone_meal", + "texture": "bworld:bone_meal" + } +} diff --git a/mods/bworld/items/wheat.json b/mods/bworld/items/wheat.json new file mode 100644 index 0000000..7c1f4b3 --- /dev/null +++ b/mods/bworld/items/wheat.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:wheat", + "texture": "bworld:wheat" + } +} diff --git a/mods/bworld/items/wheat_seeds.json b/mods/bworld/items/wheat_seeds.json new file mode 100644 index 0000000..3acb4a5 --- /dev/null +++ b/mods/bworld/items/wheat_seeds.json @@ -0,0 +1,8 @@ +{ + "format_version": 1, + "item": { + "id": "bworld:wheat_seeds", + "texture": "bworld:wheat_seeds", + "places": "bworld:wheat" + } +} diff --git a/mods/bworld/textures/bone_meal.png b/mods/bworld/textures/bone_meal.png new file mode 100644 index 0000000000000000000000000000000000000000..bc06c2865e8538268866e4a1e864e8c26a60be3f GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPE^3h)VW{r~^}r`I>{U)^=#$l}R$ z(c4!{j9>aV5h%-8666=m;PC858j$1Y>Eakt!B~4RP_Wg2hhf8)d*AMxi!hk8`Z$("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 = { diff --git a/tests/block_models_test.ts b/tests/block_models_test.ts new file mode 100644 index 0000000..ca0e8bd --- /dev/null +++ b/tests/block_models_test.ts @@ -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); +}); diff --git a/tests/mod_loading_test.ts b/tests/mod_loading_test.ts index 24bd2d3..c793797 100644 --- a/tests/mod_loading_test.ts +++ b/tests/mod_loading_test.ts @@ -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("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("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")) { diff --git a/tests/mod_tools_test.ts b/tests/mod_tools_test.ts index 839256d..7665d7e 100644 --- a/tests/mod_tools_test.ts +++ b/tests/mod_tools_test.ts @@ -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"); diff --git a/tools/check_mods.ts b/tools/check_mods.ts index e782e8b..1e5c169 100644 --- a/tools/check_mods.ts +++ b/tools/check_mods.ts @@ -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; 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(); const item_owners = new Map(); + const model_owners = new Map(); const add = (map: Map, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]); const textures = new Set(); @@ -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(", ")}`);