Basic crops system

This commit is contained in:
2026-09-25 22:49:27 -03:00
parent ef25e66f08
commit 099811670f
22 changed files with 985 additions and 178 deletions
+308
View File
@@ -0,0 +1,308 @@
// block models, in the shape of minecraft's block model json: boxes ("elements") with a texture per face.
// a block picks one with "model" and fills in its texture variables with "textures". baking turns a model
// into quads in block space (0 to 1) that the chunk mesher and the item renderers draw
import type { BlockRegistry, BlockTextures } from "./everything_registry.ts";
export type ModelFace = "top" | "bottom" | "north" | "south" | "west" | "east";
export const MODEL_FACES: ModelFace[] = ["top", "bottom", "north", "south", "west", "east"];
export interface ModelFaceJson {
// a texture variable like "#side", or a texture id
texture: string;
// the part of the texture, [u1, v1, u2, v2] in pixels (0-16). defaults to the part the face covers
uv?: [number, number, number, number];
// hidden when the neighbor on this side hides faces (a solid block)
cullface?: ModelFace;
}
export interface ModelElementJson {
// corners of the box in pixels, 0-16 is the block
from: [number, number, number];
to: [number, number, number];
rotation?: {
origin: [number, number, number];
axis: "x" | "y" | "z";
// -45, -22.5, 0, 22.5 or 45
angle: number;
// stretch the rotated faces back to the block's size, like the cross model does
rescale?: boolean;
};
// directional shading, off for plants so they look the same from every side
shade?: boolean;
faces: Partial<Record<ModelFace, ModelFaceJson>>;
}
export interface ModelJson {
id: string;
// defaults for texture variables, can point at other variables ("top": "#side")
textures?: Record<string, string>;
elements: ModelElementJson[];
}
// a model a block uses, and the textures it fills in
export interface BlockVariant {
model?: string;
textures?: BlockTextures;
// turns the model around the vertical axis, clockwise seen from above: 0, 90, 180 or 270
y?: number;
}
export const DEFAULT_MODEL = "engine:cube";
const cube_face = (texture: string, cullface: ModelFace): ModelFaceJson => ({ texture, cullface });
// the engine's own models
export const BUILTIN_MODELS: Record<string, ModelJson> = {
// a full block. "textures" can be one texture, { top, bottom, side } or { front, side }
"engine:cube": {
id: "engine:cube",
textures: { top: "#side", bottom: "#side", front: "#side" },
elements: [{
from: [0, 0, 0],
to: [16, 16, 16],
faces: {
top: cube_face("#top", "top"),
bottom: cube_face("#bottom", "bottom"),
north: cube_face("#side", "north"),
south: cube_face("#front", "south"),
west: cube_face("#side", "west"),
east: cube_face("#side", "east"),
},
}],
},
// two crossed planes, like flowers and saplings. uses the "cross" texture
"engine:cross": {
id: "engine:cross",
elements: [
cross_plane(45),
cross_plane(-45),
],
},
// four planes in a # shape, like wheat. uses the "crop" texture
"engine:crop": {
id: "engine:crop",
elements: [
{ from: [4, 0, 0], to: [4, 16, 16], shade: false, faces: both_ways("west", "east", "#crop") },
{ from: [12, 0, 0], to: [12, 16, 16], shade: false, faces: both_ways("west", "east", "#crop") },
{ from: [0, 0, 4], to: [16, 16, 4], shade: false, faces: both_ways("north", "south", "#crop") },
{ from: [0, 0, 12], to: [16, 16, 12], shade: false, faces: both_ways("north", "south", "#crop") },
],
},
};
function cross_plane(angle: number): ModelElementJson {
return {
from: [0.8, 0, 8],
to: [15.2, 16, 8],
rotation: { origin: [8, 8, 8], axis: "y", angle, rescale: true },
shade: false,
faces: both_ways("north", "south", "#cross"),
};
}
// a flat element seen from both sides, since faces are only drawn from the front
function both_ways(a: ModelFace, b: ModelFace, texture: string) {
return { [a]: { texture, uv: [0, 0, 16, 16] }, [b]: { texture, uv: [0, 0, 16, 16] } } as Partial<
Record<ModelFace, ModelFaceJson>
>;
}
export function find_model(id: string, models: Record<string, ModelJson>): ModelJson | undefined {
return BUILTIN_MODELS[id] ?? models[id];
}
// what a texture reference means for a block: "#name" looks up the block's textures, then the model's
// defaults, and anything else is already a texture id
export function resolve_texture(reference: string, textures: BlockTextures | undefined, model?: ModelJson): string {
for (let depth = 0; depth < 8 && reference.startsWith("#"); depth++) {
const name = reference.slice(1);
if (typeof textures === "string") return textures;
const next = (textures as Record<string, string> | undefined)?.[name] ?? model?.textures?.[name];
if (next === undefined) return "engine:missing";
reference = next;
}
return reference.startsWith("#") ? "engine:missing" : reference;
}
// the block's model and textures for a value with state bits, from the first variant whose conditions match
export function block_variant(block: BlockRegistry, states?: Record<string, number>): Required<BlockVariant> {
const variant: Required<BlockVariant> = {
model: block.model ?? DEFAULT_MODEL,
textures: block.textures,
y: 0,
};
if (!block.variants || !states) return variant;
for (const [condition, override] of Object.entries(block.variants)) {
if (variant_matches(condition, states)) {
return {
model: override.model ?? variant.model,
textures: override.textures ?? variant.textures,
y: override.y ?? 0,
};
}
}
return variant;
}
// "age=7" or "age=7,facing=2", "" matches everything
export function variant_matches(condition: string, states: Record<string, number>) {
if (condition === "") return true;
return condition.split(",").every((part) => {
const [name, value] = part.split("=");
return states[name.trim()] === Number(value);
});
}
// for inventories and dropped items: blocks with a full cube draw as a cube, anything else as a flat sprite
// of its first texture, like minecraft's items for plants
export function block_item_texture(block: BlockRegistry): string | undefined {
if ((block.model ?? DEFAULT_MODEL) === DEFAULT_MODEL) return undefined;
const textures = block.textures;
if (typeof textures === "string") return textures;
return Object.values(textures)[0] ?? "engine:missing";
}
export function cube_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") {
return resolve_texture(`#${face}`, block.textures, BUILTIN_MODELS[DEFAULT_MODEL]);
}
// baking
export interface BakedQuad {
// 4 corners counter clockwise seen from the front, x y z in block space
positions: number[];
// u v per corner, in pixels of the texture (0-16)
uvs: number[];
texture: string;
// the side it faces most, as the mesher's face index (see FACE_INDEX)
face: number;
// the side whose neighbor can hide it, or -1
cull: number;
// on the block's edge facing straight out, so it's lit like a full block's face
flush: boolean;
shade: boolean;
}
// the mesher's face order: top, bottom, front (+z), back (-z), left (-x), right (+x)
export const FACE_INDEX: Record<ModelFace, number> = { top: 0, bottom: 1, south: 2, north: 3, west: 4, east: 5 };
const FACE_NORMALS = [[0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1], [-1, 0, 0], [1, 0, 0]];
// each face's corners in drawing order as which end of the box they're at, same as the mesher
export const FACE_CORNERS = [
[[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]],
[[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
[[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
[[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]],
[[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
[[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]],
] as const;
// which end of the uv rectangle each corner gets, u then v
export const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const;
// the face a face becomes after turning the model 90 degrees clockwise seen from above
const TURN_Y = [0, 1, 4, 5, 3, 2];
// minecraft's default uvs: the part of the texture the face would cover if the texture was wrapped around the block
function default_uv(face: ModelFace, from: number[], to: number[]): [number, number, number, number] {
switch (face) {
case "top":
return [from[0], from[2], to[0], to[2]];
case "bottom":
return [from[0], 16 - to[2], to[0], 16 - from[2]];
case "north":
return [16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]];
case "south":
return [from[0], 16 - to[1], to[0], 16 - from[1]];
case "west":
return [from[2], 16 - to[1], to[2], 16 - from[1]];
case "east":
return [16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]];
}
}
const EPSILON = 1e-4;
export function bake_model(model: ModelJson, textures: BlockTextures | undefined, y_rotation = 0): BakedQuad[] {
const quads: BakedQuad[] = [];
const turns = ((Math.round(y_rotation / 90) % 4) + 4) % 4;
for (const element of model.elements) {
const rotate = element_rotation(element);
for (const face of MODEL_FACES) {
const face_json = element.faces[face];
if (!face_json) continue;
const index = FACE_INDEX[face];
const uv = face_json.uv ?? default_uv(face, element.from, element.to);
const positions: number[] = [];
const uvs: number[] = [];
FACE_CORNERS[index].forEach((corner, k) => {
let point = corner.map((end, axis) => (end ? element.to[axis] : element.from[axis]) / 16);
point = rotate(point);
for (let t = 0; t < turns; t++) point = [1 - point[2], point[1], point[0]];
positions.push(...point);
const [cu, cv] = CORNER_UVS[k];
uvs.push(cu ? uv[2] : uv[0], cv ? uv[3] : uv[1]);
});
let cull = face_json.cullface ? FACE_INDEX[face_json.cullface] : -1;
for (let t = 0; t < turns && cull >= 0; t++) cull = TURN_Y[cull];
const { face: facing, flush } = classify(positions);
quads.push({
positions,
uvs,
texture: resolve_texture(face_json.texture, textures, model),
face: facing,
cull,
flush,
shade: element.shade ?? true,
});
}
}
return quads;
}
// turns a point (in block space) by the element's rotation
function element_rotation(element: ModelElementJson): (point: number[]) => number[] {
const rotation = element.rotation;
if (!rotation || rotation.angle === 0) return (point) => point;
const axis = { x: 0, y: 1, z: 2 }[rotation.axis];
// the two axes that move, in the order that makes a positive angle counter clockwise looking down the axis
const [a, b] = axis === 0 ? [1, 2] : axis === 1 ? [2, 0] : [0, 1];
const radians = rotation.angle * Math.PI / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
// minecraft's rescale keeps a 45 degree face as wide as the block
const scale = rotation.rescale ? 1 / Math.max(Math.abs(cos), Math.abs(sin)) : 1;
const origin = rotation.origin.map((v) => v / 16);
return (point) => {
const out = [...point];
const da = point[a] - origin[a];
const db = point[b] - origin[b];
out[a] = origin[a] + (da * cos - db * sin) * scale;
out[b] = origin[b] + (da * sin + db * cos) * scale;
return out;
};
}
// which way a quad faces most, and whether it's flat against the block's edge
function classify(p: number[]): { face: number; flush: boolean } {
const e1 = [p[3] - p[0], p[4] - p[1], p[5] - p[2]];
const e2 = [p[9] - p[0], p[10] - p[1], p[11] - p[2]];
const normal = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
let face = 0;
let best = -Infinity;
FACE_NORMALS.forEach((n, i) => {
const dot = n[0] * normal[0] + n[1] * normal[1] + n[2] * normal[2];
if (dot > best) {
best = dot;
face = i;
}
});
const axis = FACE_NORMALS[face].findIndex((v) => v !== 0);
const edge = FACE_NORMALS[face][axis] > 0 ? 1 : 0;
const flush = [0, 1, 2, 3].every((k) => Math.abs(p[k * 3 + axis] - edge) < EPSILON);
return { face, flush };
}
+9 -17
View File
@@ -1,4 +1,5 @@
import type { ItemStack } from "./inventory.ts";
import type { 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
+111 -5
View File
@@ -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;
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");
+6
View File
@@ -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
View File
@@ -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,