From d107405f6223820627e39c9a017576c6018b1bd6 Mon Sep 17 00:00:00 2001 From: paulaboks Date: Fri, 25 Sep 2026 16:42:19 -0300 Subject: [PATCH] Q to drop --- client/client.ts | 21 ++++++++++++++++ client/game_mode.ts | 13 ++++++++++ client/options.ts | 2 ++ common/protocol.ts | 2 ++ server/game/game_server.ts | 49 ++++++++++++++++++++++++++++++++++++++ tests/item_entity_test.ts | 29 ++++++++++++++++++++++ 6 files changed, 116 insertions(+) diff --git a/client/client.ts b/client/client.ts index 4fca1ac..4f17ed9 100644 --- a/client/client.ts +++ b/client/client.ts @@ -138,6 +138,9 @@ export class Client { this.pop_screen(); } } + if (InputManager.is_key_pressed(options.key_drop)) { + this.#handle_drop(); + } if (InputManager.is_key_pressed(options.key_chat) && !this.screen) { this.push_screen(new GuiChat(this)); } @@ -165,6 +168,24 @@ export class Client { } } + // the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor) + #handle_drop() { + const all = InputManager.is_key_down("ControlLeft") || InputManager.is_key_down("ControlRight"); + const inventories = this.player.inventories; + const screen = this.screen; + if (!screen) { + this.game_mode.drop_item(inventories.inventory, "inventory", inventories.hotbar_selected, all); + return; + } + const slot = screen instanceof GuiInventoryScreen ? screen.hovering : undefined; + const container = slot && screen instanceof GuiInventoryScreen + ? screen.get_container(slot.container) + : undefined; + if (slot && container && !slot.output && !inventories.cursor.item) { + this.game_mode.drop_item(container, slot.container, slot.index, all); + } + } + // clicks happen right away, holding to break advances in tick() #handle_block_interaction() { const hit = this.hit_result; diff --git a/client/game_mode.ts b/client/game_mode.ts index 232d7bf..70f83f4 100644 --- a/client/game_mode.ts +++ b/client/game_mode.ts @@ -2,6 +2,8 @@ import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everyt import { AIR, FACE_OFFSETS, TICK_DELTA } from "$/common/constants.ts"; import type { Client } from "./client.ts"; import type { BlockHitResult } from "./level/client_level.ts"; +import type { Container } from "$/common/inventory.ts"; +import type { ContainerKey } from "$/common/protocol.ts"; // breaking and using blocks against a server, like minecraft's MultiPlayerGameMode. the client times breaking // and guesses the results so they feel instant, the server decides what really happens @@ -68,6 +70,17 @@ export class MultiPlayerGameMode { } } + // q on a slot: throws one, or the whole stack. the server spawns the item, this only takes it out right away + drop_item(container: Container, key: ContainerKey, index: number, all: boolean) { + const item = container.get_item(index); + if (!item) { + return; + } + this.client.connection.send({ type: "drop_item", container: key, index, all }); + item.amount = all ? 0 : item.amount - 1; + container.set_item(index, item.amount > 0 ? item : undefined); + } + // right click on nothing use_item() { this.client.connection.send({ type: "use_item" }); diff --git a/client/options.ts b/client/options.ts index 9c077a8..df2063f 100644 --- a/client/options.ts +++ b/client/options.ts @@ -11,6 +11,8 @@ export class Options { key_jump: KeyCode = "Space"; key_sprint: KeyCode = "ShiftLeft"; key_inventory: KeyCode = "KeyE"; + // with ctrl it drops the whole stack + key_drop: KeyCode = "KeyQ"; key_chat: KeyCode = "KeyT"; key_debug: KeyCode = "F3"; key_fullscreen: KeyCode = "F11"; diff --git a/common/protocol.ts b/common/protocol.ts index 90ae1f2..f45e35f 100644 --- a/common/protocol.ts +++ b/common/protocol.ts @@ -71,6 +71,8 @@ export type ClientMessage = | { type: "use_block"; x: number; y: number; z: number; face: Faces } | { type: "select_slot"; slot: number } | { type: "click"; container: ContainerKey; index: number; button: number } + // q on a slot (the held one when no screen is open) throws one item, or the whole stack with ctrl + | { type: "drop_item"; container: ContainerKey; index: number; all: boolean } // closes the open screen, including the player's own inventory screen | { type: "close_screen" } | { type: "chat"; text: string }; diff --git a/server/game/game_server.ts b/server/game/game_server.ts index 886883f..a9f83e0 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -310,6 +310,26 @@ export class GameServer { return entity; } + // out of the player's face the way they're looking, like minecraft's Player.drop + throw_item(player: ServerPlayer, stack: ItemStack) { + const entity = this.spawn_item( + player.x, + player.y + PLAYER_EYE_HEIGHT - 0.3, + player.z, + stack, + PLAYER_DROP_PICKUP_DELAY, + ); + // minecraft's numbers are per tick + const speed = 0.3 * TICKS_PER_SECOND; + const cos_pitch = Math.cos(player.pitch); + const spread_angle = Math.random() * Math.PI * 2; + const spread = Math.random() * 0.02 * TICKS_PER_SECOND; + entity.vx = -Math.sin(player.yaw) * cos_pitch * speed + Math.cos(spread_angle) * spread; + entity.vy = Math.sin(player.pitch) * speed + (0.1 + (Math.random() - Math.random()) * 0.1) * TICKS_PER_SECOND; + entity.vz = -Math.cos(player.yaw) * cos_pitch * speed + Math.sin(spread_angle) * spread; + return entity; + } + item_entities(): ItemEntity[] { return [...this.#entities.values()]; } @@ -504,6 +524,9 @@ export class GameServer { case "click": this.#click(player, message.container, message.index, message.button); break; + case "drop_item": + this.#drop_item(player, message.container, message.index, message.all === true); + break; case "close_screen": this.#close_screen(player); break; @@ -645,6 +668,32 @@ export class GameServer { this.mods.after.block_place.emit(place); } + #drop_item(player: ServerPlayer, key: unknown, index: unknown, all: boolean) { + if (!is_int(index)) { + return; + } + const container = this.#get_container(player, key as ContainerKey); + // the crafting result isn't an item yet, minecraft crafts it first but that's not supported + if ( + !container || index < 0 || index >= container.size || (key === "crafting" && index === CRAFTING_RESULT_SLOT) + ) { + return; + } + const item = container.get_item(index); + if (!item) { + return; + } + + const thrown = item.clone(); + thrown.amount = all ? item.amount : 1; + item.amount -= thrown.amount; + container.set_item(index, item.amount > 0 ? item : undefined); + if (key === "crafting") { + update_crafting_result(container, this.recipes.shaped); + } + this.throw_item(player, thrown); + } + #click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) { if (!is_int(index) || !is_int(button)) { return; diff --git a/tests/item_entity_test.ts b/tests/item_entity_test.ts index 5124d23..b7e2fb5 100644 --- a/tests/item_entity_test.ts +++ b/tests/item_entity_test.ts @@ -85,3 +85,32 @@ Deno.test("stacks next to each other merge, and items on the ground are saved wi const joined = reloaded.join(2, "bob"); assertEquals(joined.entities.map((entity: { item: unknown }) => entity.item), [{ id: "bworld:dirt", count: 6 }]); }); + +Deno.test("q throws one of an item the way the player looks, ctrl throws the whole stack", async () => { + const { game, take, send, join } = await test_game("mods"); + join(1, "alice"); + build_floor(game); + // looking straight along -z (yaw 0) + send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 }); + send(1, { type: "chat", text: "/give bworld:stone 5" }); + take(1); + + send(1, { type: "drop_item", container: "inventory", index: 0, all: false }); + const [thrown] = game.item_entities(); + assertEquals(thrown.item.amount, 1); + assert(thrown.vz < -5 && Math.abs(thrown.vx) < 1, "thrown forwards"); + assertEquals(inventory_count(take(1), "bworld:stone"), 4); + + for (let i = 0; i < 20; i++) game.tick(); + assert(thrown.z < -1, "it flew away from the player"); + assertEquals(game.item_entities().length, 1, "and isn't picked straight back up"); + + send(1, { type: "drop_item", container: "inventory", index: 0, all: true }); + assertEquals(game.item_entities().map((entity) => entity.item.amount).sort(), [1, 4]); + assertEquals(inventory_count(take(1), "bworld:stone"), 0); + + // empty slots and the crafting result don't throw anything + send(1, { type: "drop_item", container: "inventory", index: 0, all: true }); + send(1, { type: "drop_item", container: "crafting", index: 9, all: true }); + assertEquals(game.item_entities().length, 2); +});