Item entities
This commit is contained in:
+8
-10
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user