Smithing table
This commit is contained in:
@@ -349,8 +349,23 @@ clients as part of the container it's in, so client screens can show it.
|
||||
| `type` | Fields | Notes |
|
||||
| --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Fits anywhere in the 3×3 grid, like the recipes in `server/game/crafting.ts` |
|
||||
| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as `FURNACE_RECIPES` in `server/game/blocks.ts` |
|
||||
| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `server/game/blocks.ts` |
|
||||
| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Used by the furnace |
|
||||
| `fuel` | `item` id, `burn_time` in ticks | Used by the furnace |
|
||||
| `smithing` | `tool` item id, `material` `{ id, count }`, optional `addition` item id, `result` item id | Used by the smithing table: upgrades the tool, keeping its data. Uses up the tool, `count` of the material and the addition |
|
||||
|
||||
A smithing recipe, `recipes/smithing_stone_pickaxe.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "smithing",
|
||||
"tool": "bworld:wood_pickaxe",
|
||||
"material": { "id": "bworld:stone", "count": 5 },
|
||||
"result": "bworld:stone_pickaxe"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A shaped recipe, for the crafting grid in the player's inventory screen:
|
||||
|
||||
@@ -366,7 +381,8 @@ A shaped recipe, for the crafting grid in the player's inventory screen:
|
||||
}
|
||||
```
|
||||
|
||||
Two furnace recipes with the same `input`, or two shaped recipes with the same pattern and key, are a load error. Server
|
||||
Two furnace recipes with the same `input`, two smithing recipes with the same tool, material and addition, or two
|
||||
shaped recipes with the same pattern and key, are a load error. Server
|
||||
scripts can also register recipe types of their own through `ctx.recipes`.
|
||||
|
||||
## Server scripts
|
||||
@@ -655,7 +671,9 @@ ctx.components.register_block("copper_tools:smelter", {
|
||||
can be fractional. `rows` sets its height, by default it fits the slots and bars.
|
||||
- `filter` is a function `(item) => boolean` or one of the built-in filters `"smeltable"` (has a furnace recipe) and
|
||||
`"fuel"`. It and `output_only` run **on the server**, so players can't put the wrong items in by editing their client.
|
||||
`output_only` slots can only be taken from, all at once, like the furnace result.
|
||||
`output_only` slots can only be taken from, all at once, like the furnace result. Their `on_take(item)` runs when a
|
||||
player takes from one, before they get it: return `false` to stop them, or use up the inputs there so they're gone in
|
||||
the same step (the smithing table does this).
|
||||
- `bars` are progress bars filled with `value / max`, both names of properties set with `screen.set_property(id, n)`.
|
||||
`direction` is `"up"` (like the furnace's fire) or `"right"` (like its arrow). Properties are synced when they change,
|
||||
so a component can update them every tick.
|
||||
|
||||
@@ -258,6 +258,12 @@ function right_click(container: Container, index: number, cursor: Cursor) {
|
||||
slot.amount = half;
|
||||
}
|
||||
|
||||
// whether take_output would take it
|
||||
export function can_take_output(item: ItemStack, cursor: Cursor): boolean {
|
||||
const holding = cursor.item;
|
||||
return !holding || (holding.type_id === item.type_id && holding.max_amount - holding.amount >= item.amount);
|
||||
}
|
||||
|
||||
// output slots (furnace result, crafting result) can only be taken from, all at once
|
||||
// returns whether it was taken
|
||||
export function take_output(item: ItemStack, cursor: Cursor): boolean {
|
||||
|
||||
@@ -191,6 +191,8 @@ export interface RecipeApi {
|
||||
fuel_value(item: Id): number; // burn time in ticks, 0 if it isn't fuel
|
||||
is_fuel(item: Id): boolean;
|
||||
is_smeltable(item: Id): boolean;
|
||||
// the smithing recipe for these items, whatever the material's count. addition is the optional third item
|
||||
smithing(tool: Id, material: Id, addition?: Id): { result: Id; material_count: number } | undefined;
|
||||
}
|
||||
|
||||
// guis
|
||||
@@ -233,6 +235,8 @@ export interface ContainerScreenOptions {
|
||||
filter?: SlotFilter;
|
||||
// can only be taken from, all at once, like the furnace result
|
||||
output_only?: boolean;
|
||||
// runs when a player takes from an output_only slot, before they get it. false stops them taking it
|
||||
on_take?: (item: ItemStack) => boolean;
|
||||
}[];
|
||||
// height of the screen's area in slots, fits the layout and bars when not set
|
||||
rows?: number;
|
||||
|
||||
+18
-2
@@ -78,7 +78,15 @@ export type RecipeJson =
|
||||
result: { id: string; count: number };
|
||||
}
|
||||
| { type: "furnace"; input: string; output: { id: string; count: number }; cook_time: number }
|
||||
| { type: "fuel"; item: string; burn_time: number };
|
||||
| { type: "fuel"; item: string; burn_time: number }
|
||||
// upgrades a tool at a smithing table, with some of a material and maybe one more item
|
||||
| {
|
||||
type: "smithing";
|
||||
tool: string;
|
||||
material: { id: string; count: number };
|
||||
addition?: string;
|
||||
result: string;
|
||||
};
|
||||
|
||||
// the crafting grid's format, see server/game/crafting.ts
|
||||
export interface GridRecipe {
|
||||
@@ -447,8 +455,16 @@ export function validate_recipe(json: unknown): Problems {
|
||||
problems.push("burn_time must be a positive number of ticks");
|
||||
}
|
||||
break;
|
||||
case "smithing":
|
||||
problems.push(
|
||||
...validate_id(json.tool, "tool"),
|
||||
...validate_stack(json.material, "material"),
|
||||
...validate_id(json.result, "result"),
|
||||
);
|
||||
if (json.addition !== undefined) problems.push(...validate_id(json.addition, "addition"));
|
||||
break;
|
||||
default:
|
||||
problems.push('type must be "shaped", "furnace" or "fuel"');
|
||||
problems.push('type must be "shaped", "furnace", "fuel" or "smithing"');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export class RecipeBook {
|
||||
shaped: GridRecipe[] = [];
|
||||
furnace = new Map<string, { output: { id: string; count: number }; cook_time: number }>();
|
||||
fuel = new Map<string, number>();
|
||||
smithing: Extract<RecipeJson, { type: "smithing" }>[] = [];
|
||||
ores: OreJson[] = [];
|
||||
}
|
||||
|
||||
@@ -98,6 +99,17 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe
|
||||
case "fuel":
|
||||
recipes.fuel.set(recipe.item, recipe.burn_time);
|
||||
break;
|
||||
case "smithing":
|
||||
if (
|
||||
recipes.smithing.some((other) =>
|
||||
other.tool === recipe.tool && other.material.id === recipe.material.id &&
|
||||
other.addition === recipe.addition
|
||||
)
|
||||
) {
|
||||
fail(`two smithing recipes for ${recipe.tool} with ${recipe.material.id}`);
|
||||
}
|
||||
recipes.smithing.push(recipe);
|
||||
break;
|
||||
}
|
||||
}
|
||||
recipes.ores.push(...data.ores);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"block": {
|
||||
"id": "bworld:smithing_table",
|
||||
"textures": {
|
||||
"top": "bworld:smithing_table_top",
|
||||
"bottom": "bworld:smithing_table_bottom",
|
||||
"front": "bworld:smithing_table_front",
|
||||
"side": "bworld:smithing_table_side"
|
||||
},
|
||||
"mining": {
|
||||
"toughness": 5,
|
||||
"tool": "axe"
|
||||
},
|
||||
"drops": "bworld:smithing_table",
|
||||
"interactive": true,
|
||||
"components": {
|
||||
"bworld:smithing_table": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"item": {
|
||||
"id": "bworld:stone_pickaxe",
|
||||
"texture": "bworld:stone_pickaxe",
|
||||
"tool": "pickaxe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"recipe": {
|
||||
"type": "smithing",
|
||||
"tool": "bworld:wood_pickaxe",
|
||||
"material": {
|
||||
"id": "bworld:stone",
|
||||
"count": 5
|
||||
},
|
||||
"result": "bworld:stone_pickaxe"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export function setup(ctx: ServerContext) {
|
||||
register_hoeable(ctx);
|
||||
register_storage(ctx);
|
||||
register_furnace(ctx);
|
||||
register_smithing_table(ctx);
|
||||
register_crop(ctx);
|
||||
register_watering_can(ctx);
|
||||
}
|
||||
@@ -196,6 +197,81 @@ function register_furnace(ctx: ServerContext) {
|
||||
});
|
||||
}
|
||||
|
||||
// smithing table: a tool, some of a material and maybe a third item make a better tool, see the smithing recipes.
|
||||
// every player gets their own slots, and what's left in them goes back to the player when they close it
|
||||
|
||||
const TOOL = 0;
|
||||
const MATERIAL = 1;
|
||||
const ADDITION = 2;
|
||||
const RESULT = 3;
|
||||
|
||||
function register_smithing_table(ctx: ServerContext) {
|
||||
ctx.components.register_block("bworld:smithing_table", {
|
||||
on_interact(_block, _params, player) {
|
||||
const container = ctx.containers.create(4);
|
||||
|
||||
// the recipe the inputs make, with enough material
|
||||
const match = () => {
|
||||
const tool = container.get(TOOL);
|
||||
const material = container.get(MATERIAL);
|
||||
const addition = container.get(ADDITION);
|
||||
if (!tool || !material) return undefined;
|
||||
const recipe = ctx.recipes.smithing(tool.id, material.id, addition?.id);
|
||||
if (!recipe || material.count < recipe.material_count) return undefined;
|
||||
return { ...recipe, tool, material, addition };
|
||||
};
|
||||
|
||||
// the result slot shows what taking it would give, the upgraded tool keeps the old one's data
|
||||
const show_result = () => {
|
||||
const recipe = match();
|
||||
const shown = container.get(RESULT);
|
||||
if (!recipe) {
|
||||
if (shown) container.set(RESULT, undefined);
|
||||
} else if (shown?.id !== recipe.result) {
|
||||
container.set(RESULT, { id: recipe.result, count: 1, data: recipe.tool.data });
|
||||
}
|
||||
};
|
||||
const stop = container.on_change((slot) => {
|
||||
if (slot !== RESULT) show_result();
|
||||
});
|
||||
|
||||
const screen = ctx.ui.open_container(player, {
|
||||
container,
|
||||
layout: [
|
||||
{ slot: TOOL, x: 1.5, y: 0.5 },
|
||||
{ slot: MATERIAL, x: 2.5, y: 0.5 },
|
||||
{ slot: ADDITION, x: 3.5, y: 0.5 },
|
||||
{
|
||||
slot: RESULT,
|
||||
x: 5.5,
|
||||
y: 0.5,
|
||||
output_only: true,
|
||||
// the inputs are used up in the same step, so they can't be taken out after
|
||||
on_take(item) {
|
||||
const recipe = match();
|
||||
if (!recipe || recipe.result !== item.id) return false;
|
||||
recipe.tool.count -= 1;
|
||||
recipe.material.count -= recipe.material_count;
|
||||
if (recipe.addition) recipe.addition.count -= 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
rows: 2,
|
||||
});
|
||||
screen.on_close(() => {
|
||||
stop();
|
||||
for (const slot of [TOOL, MATERIAL, ADDITION]) {
|
||||
const item = container.get(slot);
|
||||
if (item) player.give_item(item.id, item.count, item.data);
|
||||
}
|
||||
ctx.containers.delete(container.id);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// grows one stage each time it's right clicked with the fertilizer, like bone meal on wheat
|
||||
function register_crop(ctx: ServerContext) {
|
||||
ctx.components.register_block<{ fertilizer: string; max_age: number }>("bworld:crop", {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TICKS_PER_SECOND,
|
||||
} from "$/common/constants.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts";
|
||||
import { can_take_output, click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts";
|
||||
import {
|
||||
AIR_ID,
|
||||
BlockChange,
|
||||
@@ -744,7 +744,8 @@ export class GameServer {
|
||||
}
|
||||
} else if (key === "screen" && player.screen?.layout.slots.find((s) => s.index === index)?.output) {
|
||||
const item = container.get_item(index);
|
||||
if (item && take_output(item, player.cursor)) {
|
||||
const allowed = (item: ItemStack) => player.screen?.on_take.get(index)?.(item) ?? true;
|
||||
if (item && can_take_output(item, player.cursor) && allowed(item) && take_output(item, player.cursor)) {
|
||||
container.set_item(index, undefined);
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -391,6 +391,12 @@ export class ModRuntime {
|
||||
fuel_value: (item) => game().recipes.fuel.get(item) ?? 0,
|
||||
is_fuel: (item) => game().recipes.fuel.has(item),
|
||||
is_smeltable: (item) => game().recipes.furnace.has(item),
|
||||
smithing: (tool, material, addition) => {
|
||||
const recipe = game().recipes.smithing.find((r) =>
|
||||
r.tool === tool && r.material.id === material && r.addition === addition
|
||||
);
|
||||
return recipe && { result: recipe.result, material_count: recipe.material.count };
|
||||
},
|
||||
},
|
||||
containers: {
|
||||
create: (size) => {
|
||||
@@ -442,7 +448,11 @@ export class ModRuntime {
|
||||
}
|
||||
|
||||
const filters = new Map<number, (item: EngineItemStack) => boolean>();
|
||||
for (const { slot, filter } of options.layout) {
|
||||
const on_take = new Map<number, (item: EngineItemStack) => boolean>();
|
||||
for (const { slot, filter, on_take: take } of options.layout) {
|
||||
if (take) {
|
||||
on_take.set(slot, (item) => run_guarded(mod, "an on_take", () => take(item_api(item))) === true);
|
||||
}
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot >= container.size) {
|
||||
throw new Error(`slot ${slot} isn't in the container, it has ${container.size}`);
|
||||
}
|
||||
@@ -474,7 +484,14 @@ export class ModRuntime {
|
||||
};
|
||||
|
||||
let open = true;
|
||||
const screen: OpenScreen = { container, layout, properties: {}, filters, on_close: [() => open = false] };
|
||||
const screen: OpenScreen = {
|
||||
container,
|
||||
layout,
|
||||
properties: {},
|
||||
filters,
|
||||
on_take,
|
||||
on_close: [() => open = false],
|
||||
};
|
||||
game.open_screen(server_player, screen);
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface OpenScreen {
|
||||
properties: Record<string, number>;
|
||||
// what can go into each slot, checked when the player clicks
|
||||
filters: Map<number, (item: ItemStack) => boolean>;
|
||||
// asked before a player takes from an output slot
|
||||
on_take: Map<number, (item: ItemStack) => boolean>;
|
||||
on_close: (() => void)[];
|
||||
}
|
||||
|
||||
|
||||
@@ -195,3 +195,70 @@ Deno.test("chests and furnaces from a world saved before they were mod code keep
|
||||
assert(saved.tiles.every((tile: Msg) => tile.data === undefined && tile.containers === undefined));
|
||||
assertEquals(Object.keys(saved.containers).length, 2);
|
||||
});
|
||||
|
||||
Deno.test("a smithing table upgrades a wooden pickaxe with 5 stone, using up exactly that", async () => {
|
||||
const t = await setup([["bworld:wood_pickaxe", 1], ["bworld:stone", 7]]);
|
||||
t.game.set_block(1, 100, 0, "bworld:smithing_table");
|
||||
let messages = t.take(1);
|
||||
const pickaxe = slot_of(messages, "bworld:wood_pickaxe");
|
||||
const stone = slot_of(messages, "bworld:stone");
|
||||
|
||||
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
|
||||
const opened = t.take(1).find((m) => m.type === "open_screen");
|
||||
assertEquals(opened.layout.slots.map((s: Msg) => [s.index, s.output ?? false]), [
|
||||
[0, false],
|
||||
[1, false],
|
||||
[2, false],
|
||||
[3, true],
|
||||
]);
|
||||
|
||||
// with only the tool there's nothing to take
|
||||
move_to_screen(t, pickaxe, 0);
|
||||
t.game.tick();
|
||||
t.send(1, { type: "click", container: "screen", index: 3, button: 0 });
|
||||
assertEquals(t.take(1).filter((m) => m.type === "cursor").at(-1)?.item ?? null, null);
|
||||
|
||||
// the result shows up once there's enough stone, and taking it uses up the pickaxe and 5 stone
|
||||
move_to_screen(t, stone, 1);
|
||||
t.game.tick();
|
||||
assertEquals(last_container(t.take(1), "screen")[3], { id: "bworld:stone_pickaxe", count: 1 });
|
||||
t.send(1, { type: "click", container: "screen", index: 3, button: 0 });
|
||||
messages = t.take(1);
|
||||
assertEquals(messages.filter((m) => m.type === "cursor").at(-1).item, { id: "bworld:stone_pickaxe", count: 1 });
|
||||
assertEquals(last_container(messages, "screen"), [null, { id: "bworld:stone", count: 2 }, null, null]);
|
||||
|
||||
// closing gives back the leftover stone, and the cursor's pickaxe goes into the inventory
|
||||
t.send(1, { type: "close_screen" });
|
||||
const inventory = last_container(t.take(1), "inventory").filter((i: Msg) => i);
|
||||
assertEquals(
|
||||
inventory.sort((a: Msg, b: Msg) => a.id.localeCompare(b.id)),
|
||||
[{ id: "bworld:stone", count: 2 }, { id: "bworld:stone_pickaxe", count: 1 }],
|
||||
);
|
||||
assertEquals(t.game.containers.size, 0);
|
||||
});
|
||||
|
||||
Deno.test("a smithing result can't be taken once its inputs are gone, even before it updates", async () => {
|
||||
const t = await setup([["bworld:wood_pickaxe", 1], ["bworld:stone", 5]]);
|
||||
t.game.set_block(1, 100, 0, "bworld:smithing_table");
|
||||
const messages = t.take(1);
|
||||
t.send(1, { type: "use_block", x: 1, y: 100, z: 0, face: "top" });
|
||||
move_to_screen(t, slot_of(messages, "bworld:wood_pickaxe"), 0);
|
||||
move_to_screen(t, slot_of(messages, "bworld:stone"), 1);
|
||||
t.game.tick();
|
||||
|
||||
// take the pickaxe back out and try the result in the same tick
|
||||
t.send(1, { type: "click", container: "screen", index: 0, button: 0 });
|
||||
t.send(1, { type: "click", container: "inventory", index: 20, button: 0 });
|
||||
t.send(1, { type: "click", container: "screen", index: 3, button: 0 });
|
||||
const cursor = t.take(1).filter((m) => m.type === "cursor").at(-1).item;
|
||||
assertEquals(cursor, null);
|
||||
|
||||
// and an extra item in the third slot doesn't match a recipe without one
|
||||
t.send(1, { type: "click", container: "inventory", index: 20, button: 0 });
|
||||
t.send(1, { type: "click", container: "screen", index: 0, button: 0 });
|
||||
t.send(1, { type: "chat", text: "/give bworld:coal 1" });
|
||||
const coal = slot_of(t.take(1), "bworld:coal");
|
||||
move_to_screen(t, coal, 2);
|
||||
t.game.tick();
|
||||
assertEquals(last_container(t.take(1), "screen")[3], null);
|
||||
});
|
||||
|
||||
@@ -23,9 +23,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, 20);
|
||||
// 14 items plus the item forms of the 17 blocks that have one
|
||||
assertEquals(EverythingRegistry.entries("items").length, 31);
|
||||
assertEquals(EverythingRegistry.entries("blocks").length, 21);
|
||||
// 15 items plus the item forms of the 18 blocks that have one
|
||||
assertEquals(EverythingRegistry.entries("items").length, 33);
|
||||
assertEquals(game.recipes.shaped.length, 5);
|
||||
assertEquals(game.recipes.furnace.size, 6);
|
||||
assertEquals(game.recipes.fuel.size, 2);
|
||||
|
||||
@@ -280,6 +280,12 @@ function check_references(mods: LoadedMod[]) {
|
||||
case "fuel":
|
||||
uses(file, json.item, "item");
|
||||
break;
|
||||
case "smithing":
|
||||
uses(file, json.tool, "item");
|
||||
uses(file, json.material.id, "item");
|
||||
if (json.addition) uses(file, json.addition, "item");
|
||||
uses(file, json.result, "item");
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const { file, json } of mod.ores) {
|
||||
|
||||
Reference in New Issue
Block a user