Item entities

This commit is contained in:
2026-09-25 16:26:45 -03:00
parent 5fb23cf404
commit a254b96f65
18 changed files with 797 additions and 94 deletions
+8 -10
View File
@@ -23,16 +23,14 @@ export interface BlockBehavior {
export const BLOCK_BEHAVIORS: Record<string, BlockBehavior> = {};
// give the breaking player whatever was inside
function give_container_contents(tile: Tile, player: ServerPlayer | undefined) {
if (!player) {
return;
}
// whatever was inside falls out, like minecraft's Containers.dropContents
function drop_container_contents(game: GameServer, tile: Tile) {
for (const container of Object.values(tile.containers)) {
for (let i = 0; i < container.size; i++) {
const item = container.get_item(i);
if (item) {
player.give(item);
container.set_item(i, undefined);
game.pop_item(tile.x, tile.y, tile.z, item);
}
}
}
@@ -75,8 +73,8 @@ BLOCK_BEHAVIORS["bworld:chest"] = {
});
return true;
},
on_break(_game, tile, player) {
give_container_contents(tile, player);
on_break(game, tile) {
drop_container_contents(game, tile);
},
};
@@ -201,8 +199,8 @@ BLOCK_BEHAVIORS["bworld:furnace"] = {
});
return true;
},
on_break(_game, tile, player) {
give_container_contents(tile, player);
on_break(game, tile) {
drop_container_contents(game, tile);
},
on_tick(game, tile) {
const data = tile.data as unknown as FurnaceData;
+173 -6
View File
@@ -5,6 +5,9 @@ import {
FACE_OFFSETS,
Faces,
faces,
PLAYER_EYE_HEIGHT,
PLAYER_HEIGHT,
PLAYER_WIDTH,
TICK_DELTA,
TICKS_PER_SECOND,
} from "$/common/constants.ts";
@@ -29,10 +32,22 @@ import type { GameLoop } from "./game_loop.ts";
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
import { ServerWorld, Tile } from "./world.ts";
import {
BLOCK_DROP_PICKUP_DELAY,
DESPAWN_TICKS,
ITEM_SIZE,
ItemEntity,
PLAYER_DROP_PICKUP_DELAY,
SavedItemEntity,
} from "./item_entity.ts";
// how far from a player's eyes a block can be changed, a bit more than the client's reach
const MAX_REACH = 8;
const EYE_HEIGHT = 1.69;
// how far past a player's box items get picked up from, sideways and up or down, like minecraft
const PICKUP_REACH_XZ = 1;
const PICKUP_REACH_Y = 0.5;
// items this close (past their own size) merge into one stack
const MERGE_DISTANCE = 0.5;
// tiles further than this many chunks from every player don't tick
const SIMULATION_DISTANCE = 6;
const MAX_GIVE = 64 * 36;
@@ -60,6 +75,8 @@ export interface SavedWorld {
mod_data?: unknown;
}[];
players: Record<string, SavedPlayer>;
// items on the ground
entities?: SavedItemEntity[];
// ctx.storage of every mod, by mod id
mod_storage?: Record<string, Record<string, unknown>>;
}
@@ -81,6 +98,9 @@ export class GameServer {
#pending = new Map<number, { name: unknown; since: number }>();
#saved_players: Record<string, SavedPlayer> = {};
#tick = 0;
// items on the ground, by id
#entities = new Map<string, ItemEntity>();
#next_entity_id = 1;
#mods: GameMods;
mods: ModRuntime;
recipes: RecipeBook;
@@ -106,6 +126,10 @@ export class GameServer {
}
this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers });
}
for (const entity of saved?.entities ?? []) {
const item = ItemEntity.load(this.#new_entity_id(), entity);
this.#entities.set(item.id, item);
}
this.#saved_players = saved?.players ?? {};
this.world.dirty = saved?.version !== 2;
@@ -189,6 +213,8 @@ export class GameServer {
this.world.dirty = true;
}
this.#tick_items();
this.mods.tick();
this.mods.check_watched_containers();
@@ -224,6 +250,7 @@ export class GameServer {
mod_data: tile.mod_data,
})),
players: this.#saved_players,
entities: [...this.#entities.values()].map((entity) => entity.save()),
mod_storage: this.mods.storage,
};
this.world.dirty = false;
@@ -248,10 +275,45 @@ export class GameServer {
stack.amount = Math.min(left, stack.max_amount);
if (data !== undefined) stack.data = structuredClone(data);
left -= stack.amount;
player.give(stack);
this.give_stack(player, stack);
}
}
// into the inventory, whatever doesn't fit drops at the player's feet
give_stack(player: ServerPlayer, stack: ItemStack) {
if (player.inventory.add_item(stack) > 0) {
this.spawn_item(player.x, player.y, player.z, stack, PLAYER_DROP_PICKUP_DELAY);
}
}
spawn_item(x: number, y: number, z: number, stack: ItemStack, pickup_delay = 0): ItemEntity {
const entity = new ItemEntity(this.#new_entity_id(), stack, x, y, z, pickup_delay);
this.#entities.set(entity.id, entity);
this.#broadcast({ type: "add_entity", entity: entity.info() });
this.world.dirty = true;
return entity;
}
// like minecraft's Block.popResource: from a bit off the block's middle, flying up and out a little
pop_item(x: number, y: number, z: number, stack: ItemStack) {
const spread = () => (Math.random() - 0.5) * 0.5;
const entity = this.spawn_item(
x + 0.5 + spread(),
y + 0.5 - ITEM_SIZE / 2 + spread(),
z + 0.5 + spread(),
stack,
BLOCK_DROP_PICKUP_DELAY,
);
entity.vx = (Math.random() - 0.5) * 4;
entity.vy = 4;
entity.vz = (Math.random() - 0.5) * 4;
return entity;
}
item_entities(): ItemEntity[] {
return [...this.#entities.values()];
}
send_chat(player: ServerPlayer, text: string) {
this.#send(player, { type: "chat", text });
}
@@ -392,6 +454,7 @@ export class GameServer {
name: player.name,
players: [...this.#players.values()].map((p) => p.info()),
changes: this.world.all_changes(),
entities: [...this.#entities.values()].map((entity) => entity.info()),
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
selected_slot: player.selected_slot,
});
@@ -475,7 +538,7 @@ export class GameServer {
this.set_block(x, y, z, AIR_ID, player);
if (drops) {
player.give(new ItemStack(info.drop_table!));
this.pop_item(x, y, z, new ItemStack(info.drop_table!));
}
this.mods.after.block_break.emit(event);
}
@@ -687,14 +750,118 @@ export class GameServer {
for (let i = 0; i < 9; i++) {
const item = player.crafting.get_item(i);
if (item) {
player.give(item);
player.crafting.set_item(i, undefined);
this.give_stack(player, item);
}
}
update_crafting_result(player.crafting, this.recipes.shaped);
if (player.cursor.item) {
player.give(player.cursor.item);
const item = player.cursor.item;
player.cursor.item = undefined;
this.give_stack(player, item);
}
}
// items on the ground
#new_entity_id() {
return `item${this.#next_entity_id++}`;
}
#remove_item(entity: ItemEntity) {
this.#entities.delete(entity.id);
this.#broadcast({ type: "remove_entity", id: entity.id });
}
// like minecraft's ItemEntity.tick: only near players, falling, merging, despawning, and getting picked up
#tick_items() {
if (this.#entities.size === 0) {
return;
}
const is_solid = (x: number, y: number, z: number) => this.world.get_block_nid(x, y, z) !== AIR;
const active = [...this.#entities.values()].filter((entity) => this.#near_any_player(entity.x, entity.z));
for (const entity of active) {
entity.tick(is_solid);
if (entity.age >= DESPAWN_TICKS) {
this.#remove_item(entity);
}
}
if (this.#tick % 2 === 0) {
this.#merge_items(active.filter((entity) => this.#entities.has(entity.id)));
}
for (const player of this.#players.values()) {
for (const entity of active) {
if (this.#entities.has(entity.id) && entity.pickup_delay === 0 && this.#can_pick_up(player, entity)) {
this.#pick_up(player, entity);
}
}
}
for (const entity of active) {
const moved = entity.x !== entity.sent_x || entity.y !== entity.sent_y || entity.z !== entity.sent_z;
if (moved && this.#entities.has(entity.id)) {
entity.sent_x = entity.x;
entity.sent_y = entity.y;
entity.sent_z = entity.z;
this.#broadcast({ type: "move_entity", id: entity.id, x: entity.x, y: entity.y, z: entity.z });
}
}
this.world.dirty = true;
}
// the smaller stack goes into the bigger one, like minecraft's ItemEntity.tryToMerge
#merge_items(entities: ItemEntity[]) {
for (const a of entities) {
for (const b of entities) {
if (!this.#entities.has(a.id) || !this.#entities.has(b.id) || !a.can_merge_with(b)) {
continue;
}
const reach = MERGE_DISTANCE + ITEM_SIZE;
if (Math.abs(a.x - b.x) > reach || Math.abs(a.z - b.z) > reach || Math.abs(a.y - b.y) > ITEM_SIZE) {
continue;
}
const [into, from] = a.item.amount >= b.item.amount ? [a, b] : [b, a];
const moving = Math.min(from.item.amount, into.item.max_amount - into.item.amount);
into.item.amount += moving;
from.item.amount -= moving;
into.pickup_delay = Math.max(into.pickup_delay, from.pickup_delay);
into.age = Math.min(into.age, from.age);
this.#broadcast({ type: "set_entity_item", id: into.id, item: into.item.to_data() });
if (from.item.amount === 0) {
this.#remove_item(from);
} else {
this.#broadcast({ type: "set_entity_item", id: from.id, item: from.item.to_data() });
}
}
}
}
// the player's box grown by the pickup reach touches the item's box
#can_pick_up(player: ServerPlayer, entity: ItemEntity) {
const reach_xz = PLAYER_WIDTH / 2 + PICKUP_REACH_XZ + ITEM_SIZE / 2;
return Math.abs(player.x - entity.x) <= reach_xz && Math.abs(player.z - entity.z) <= reach_xz &&
entity.y + ITEM_SIZE >= player.y - PICKUP_REACH_Y &&
entity.y <= player.y + PLAYER_HEIGHT + PICKUP_REACH_Y;
}
// as much as fits, the rest stays on the ground
#pick_up(player: ServerPlayer, entity: ItemEntity) {
const before = entity.item.amount;
const left = player.inventory.add_item(entity.item);
if (left === before) {
return;
}
if (left === 0) {
this.#entities.delete(entity.id);
this.#broadcast({ type: "take_entity", id: entity.id, player: player.id });
} else {
this.#broadcast({ type: "set_entity_item", id: entity.id, item: entity.item.to_data() });
}
}
@@ -751,7 +918,7 @@ export class GameServer {
#in_reach(player: ServerPlayer, x: number, y: number, z: number) {
const dx = x + 0.5 - player.x;
const dy = y + 0.5 - (player.y + EYE_HEIGHT);
const dy = y + 0.5 - (player.y + PLAYER_EYE_HEIGHT);
const dz = z + 0.5 - player.z;
return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH;
}
+123
View File
@@ -0,0 +1,123 @@
import { ItemData, ItemStack } from "$/common/inventory.ts";
import { move_body } from "$/common/physics.ts";
import type { EntityInfo } from "$/common/protocol.ts";
// minecraft's item physics, its per tick numbers turned into per second ones where they're speeds
export const ITEM_SIZE = 0.25;
// 0.04 blocks per tick, per tick
const GRAVITY = -16;
// what's left of the speed after each tick
const AIR_DRAG = 0.98;
const GROUND_FRICTION = 0.6 * 0.98;
// slower than this counts as stopped, so items don't creep along forever
const MIN_SPEED = 0.01;
// how fast an item stuck inside a block floats out of it
const UNSTICK_SPEED = 2;
// 5 minutes
export const DESPAWN_TICKS = 6000;
// how long before a popped block drop can be picked up, and one a player drops
export const BLOCK_DROP_PICKUP_DELAY = 10;
export const PLAYER_DROP_PICKUP_DELAY = 40;
// what's saved about an item on the ground
export interface SavedItemEntity {
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
item: ItemData;
age: number;
pickup_delay: number;
}
// a stack of items lying in the world, like minecraft's ItemEntity. only the server simulates it,
// clients draw where they're told it is
export class ItemEntity {
readonly id: string;
item: ItemStack;
x: number;
y: number;
z: number;
vx = 0;
vy = 0;
vz = 0;
readonly width = ITEM_SIZE;
readonly height = ITEM_SIZE;
age = 0;
// ticks until a player can pick it up
pickup_delay: number;
on_ground = false;
// the position clients were last sent, so it's only sent again when it moves
sent_x: number;
sent_y: number;
sent_z: number;
constructor(id: string, item: ItemStack, x: number, y: number, z: number, pickup_delay = 0) {
this.id = id;
this.item = item;
this.x = this.sent_x = x;
this.y = this.sent_y = y;
this.z = this.sent_z = z;
this.pickup_delay = pickup_delay;
}
tick(is_solid: (x: number, y: number, z: number) => boolean) {
this.age += 1;
if (this.pickup_delay > 0) {
this.pickup_delay -= 1;
}
// covered by a block placed on it, float out the top instead of being stuck inside
if (is_solid(Math.floor(this.x), Math.floor(this.y + this.height / 2), Math.floor(this.z))) {
this.vy = UNSTICK_SPEED;
}
const collisions = move_body(this, GRAVITY, is_solid);
this.on_ground = collisions.y === 1;
const friction = this.on_ground ? GROUND_FRICTION : AIR_DRAG;
this.vx *= friction;
this.vy *= AIR_DRAG;
this.vz *= friction;
if (Math.abs(this.vx) < MIN_SPEED) this.vx = 0;
if (Math.abs(this.vz) < MIN_SPEED) this.vz = 0;
}
// whether two stacks could be one
can_merge_with(other: ItemEntity) {
return other !== this && other.item.type_id === this.item.type_id &&
JSON.stringify(other.item.data) === JSON.stringify(this.item.data) &&
this.item.amount < this.item.max_amount && other.item.amount < other.item.max_amount;
}
info(): EntityInfo {
return { kind: "item", id: this.id, x: this.x, y: this.y, z: this.z, item: this.item.to_data() };
}
save(): SavedItemEntity {
const { x, y, z, vx, vy, vz, age, pickup_delay } = this;
return { x, y, z, vx, vy, vz, age, pickup_delay, item: this.item.to_data() };
}
static load(id: string, saved: SavedItemEntity) {
const entity = new ItemEntity(
id,
ItemStack.from_data(saved.item),
saved.x,
saved.y,
saved.z,
saved.pickup_delay,
);
entity.vx = saved.vx;
entity.vy = saved.vy;
entity.vz = saved.vz;
entity.age = saved.age;
return entity;
}
}
-5
View File
@@ -53,11 +53,6 @@ export class ServerPlayer {
return this.inventory.get_item(this.selected_slot);
}
give(item: ItemStack) {
// TODO: drop what doesn't fit once items can be on the ground
this.inventory.add_item(item);
}
info(): PlayerInfo {
return { id: this.id, name: this.name, x: this.x, y: this.y, z: this.z, yaw: this.yaw, pitch: this.pitch };
}