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
+74
View File
@@ -0,0 +1,74 @@
import { assert, assertEquals } from "@std/assert";
import { bake_model, block_variant, BUILTIN_MODELS, type ModelJson, resolve_texture } from "$/common/block_models.ts";
import { block_from_json } from "$/common/mod_data.ts";
import { set_state_value } from "$/common/utils.ts";
const cube = BUILTIN_MODELS["engine:cube"];
Deno.test("cubes resolve the old texture shapes", () => {
assertEquals(resolve_texture("#top", "a:all", cube), "a:all");
const pillar = { top: "a:top", bottom: "a:bottom", side: "a:side" };
assertEquals(["#top", "#bottom", "#front", "#side"].map((t) => resolve_texture(t, pillar, cube)), [
"a:top",
"a:bottom",
"a:side",
"a:side",
]);
const furnace = { front: "a:front", side: "a:side" };
assertEquals(["#top", "#bottom", "#front"].map((t) => resolve_texture(t, furnace, cube)), [
"a:side",
"a:side",
"a:front",
]);
assertEquals(resolve_texture("#nothing", { crop: "a:crop" }), "engine:missing");
});
Deno.test("a cube bakes into six full faces that cull against their neighbors", () => {
const quads = bake_model(cube, "a:all");
assertEquals(quads.length, 6);
assert(quads.every((q) => q.flush && q.cull === q.face && q.texture === "a:all"));
// the top face, corners and uvs like the mesher always had
assertEquals(quads[0].positions, [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0]);
assertEquals(quads[0].uvs, [0, 16, 16, 16, 16, 0, 0, 0]);
});
Deno.test("crop and cross models are planes inside the block, seen from both sides", () => {
const crop = bake_model(BUILTIN_MODELS["engine:crop"], { crop: "a:wheat" });
assertEquals(crop.length, 8);
assert(crop.every((q) => !q.flush && q.cull === -1 && !q.shade && q.texture === "a:wheat"));
assertEquals(crop[0].positions.filter((_, i) => i % 3 === 0), [0.25, 0.25, 0.25, 0.25]);
const cross = bake_model(BUILTIN_MODELS["engine:cross"], { cross: "a:flower" });
assertEquals(cross.length, 4);
// rescaled so the diagonal planes stay as wide as the block, like minecraft's (0.8 to 15.2 pixels)
for (const quad of cross) {
for (const v of quad.positions) assert(v > -1e-6 && v < 1 + 1e-6, `${v} is outside the block`);
}
const xs = cross[0].positions.filter((_, i) => i % 3 === 0).map((v) => Math.round(v * 1000) / 1000);
assertEquals(new Set(xs), new Set([0.05, 0.95]));
});
Deno.test("turning a model moves its faces and cullfaces", () => {
const model: ModelJson = {
id: "a:half",
elements: [{
from: [0, 0, 0],
to: [16, 16, 8],
faces: { north: { texture: "a:x", cullface: "north" } },
}],
};
const [north] = bake_model(model, undefined, 0);
const [east] = bake_model(model, undefined, 90);
assertEquals([north.face, north.cull], [3, 3]);
assertEquals([east.face, east.cull], [5, 5]);
assert(east.flush);
});
Deno.test("variants pick textures by state", () => {
const { block } = block_from_json(JSON.parse(Deno.readTextFileSync("mods/bworld/blocks/wheat.json")).block);
assertEquals(block_variant(block, { age: 5 }).textures, { crop: "bworld:wheat_stage_5" });
assertEquals(block_variant(block, { age: 5 }).model, "engine:crop");
assertEquals(block_variant(block).textures, { crop: "bworld:wheat_stage_0" });
// states live above the numeric id
assertEquals(set_state_value(7, block, "age", 3), (3 << 16) | 7);
});
+35 -3
View File
@@ -3,6 +3,7 @@ import { AIR, CHUNK_HEIGHT } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { 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")) {
+18
View File
@@ -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");