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
+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;
}