diff --git a/MODS.md b/MODS.md index 44ecd37..f0f0049 100644 --- a/MODS.md +++ b/MODS.md @@ -204,7 +204,7 @@ program**, so the server and each client can number blocks differently. Saves an | `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. | | `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. | | `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. | -| `drops` | nothing | `drop_table` | Item id given when broken. | +| `drops` | nothing | `drop_table` | Item id dropped on the ground when broken, for players to pick up. | | `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. | | `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. | | `replaceable` | `false` | new | Placing a block into it replaces it, like water. | @@ -408,6 +408,7 @@ interface Player { readonly inventory: Container; // 36 slots, hotbar is 0-8 readonly selected_slot: number; readonly held_item: ItemStack | undefined; + // what doesn't fit in the inventory drops at their feet give_item(id: string, count?: number, data?: unknown): void; send_message(text: string): void; teleport(x: number, y: number, z: number): void; @@ -852,8 +853,8 @@ client server │ │ │ ready │ ├────────────────────────────────────────►│ - │ join { id, name, players, changes, │ the player is created here, player_join fires, - │ spawn, selected_slot } │ and their inventory follows as container messages + │ join { id, name, players, entities, │ the player is created here, player_join fires, + │ changes, spawn, selected_slot } │ and their inventory follows as container messages │◄────────────────────────────────────────┤ ``` diff --git a/client/client.ts b/client/client.ts index b14da88..4fca1ac 100644 --- a/client/client.ts +++ b/client/client.ts @@ -2,6 +2,7 @@ import { ClientLevel } from "./level/client_level.ts"; import type { BlockHitResult } from "./level/client_level.ts"; import { LocalPlayer } from "./entity/local_player.ts"; import { RemotePlayer } from "./entity/remote_player.ts"; +import { ItemEntity } from "./entity/item_entity.ts"; import { Camera } from "./camera.ts"; import { Options } from "./options.ts"; import { MultiPlayerGameMode } from "./game_mode.ts"; @@ -67,6 +68,9 @@ export class Client { for (const info of connection.initial_players) { this.level.add_entity(new RemotePlayer(this.level, info)); } + for (const info of connection.initial_entities) { + this.level.add_entity(new ItemEntity(this.level, info)); + } this.game_mode = new MultiPlayerGameMode(this); this.packet_listener = new ClientPacketListener(this); diff --git a/client/entity/entity.ts b/client/entity/entity.ts index 0a81853..b2d5a89 100644 --- a/client/entity/entity.ts +++ b/client/entity/entity.ts @@ -1,11 +1,7 @@ -import { TICK_DELTA } from "$/common/constants.ts"; +import { AIR } from "$/common/constants.ts"; +import { move_body } from "$/common/physics.ts"; import type { ClientLevel } from "../level/client_level.ts"; -// how far inside a block face still counts as touching it, so boxes resting exactly on a face don't snag -const EPSILON = 1e-7; -// the two axes that aren't the one being moved along -const OTHER_AXES = [[1, 2], [0, 2], [0, 1]] as const; - // anything that exists in the level and moves, like minecraft's Entity. position is the middle of its feet. // it's simulated in fixed ticks (common/constants.ts), frames draw it between its last two positions export abstract class Entity { @@ -75,67 +71,11 @@ export abstract class Entity { // one step of the game, TICK_DELTA seconds abstract tick(): void; - // falls and moves by its velocity for one tick. like minecraft it moves along y, then x, then z, each time only - // as far as it can before touching a block, and stops its velocity on the axes it hit something + // falls and moves by its velocity for one tick, see move_body move() { - // the average of this tick's start and end speed, so the arc is the same at any tick rate - const wanted_y = (this.vy + this.gravity * TICK_DELTA / 2) * TICK_DELTA; - this.vy += this.gravity * TICK_DELTA; - const wanted = [this.vx * TICK_DELTA, wanted_y, this.vz * TICK_DELTA]; - - const half = this.width / 2; - const min = [this.x - half, this.y, this.z - half]; - const max = [this.x + half, this.y + this.height, this.z + half]; - const moved = [0, 0, 0]; - for (const axis of [1, 0, 2]) { - const distance = this.#clip(min, max, axis, wanted[axis]); - min[axis] += distance; - max[axis] += distance; - moved[axis] = distance; - } - - const hit = (axis: number) => moved[axis] !== wanted[axis] ? -Math.sign(wanted[axis]) : 0; - this.colliding_x = hit(0); - this.colliding_y = hit(1); - this.colliding_z = hit(2); - if (this.colliding_x !== 0) this.vx = 0; - if (this.colliding_y !== 0) this.vy = 0; - if (this.colliding_z !== 0) this.vz = 0; - - this.x += moved[0]; - this.y += moved[1]; - this.z += moved[2]; - } - - // how far the box can go along an axis before it runs into a block, unloaded chunks included. - // blocks it's already inside don't stop it, so it can get out of them - #clip(min: number[], max: number[], axis: number, distance: number) { - if (distance === 0) { - return 0; - } - - const [a, b] = OTHER_AXES[axis]; - const from = Math.floor(Math.min(min[axis], min[axis] + distance)); - const to = Math.floor(Math.max(max[axis], max[axis] + distance)); - const position = [0, 0, 0]; - - for (let i = from; i <= to; i++) { - for (let j = Math.floor(min[a] + EPSILON); j <= Math.floor(max[a] - EPSILON); j++) { - for (let k = Math.floor(min[b] + EPSILON); k <= Math.floor(max[b] - EPSILON); k++) { - position[axis] = i; - position[a] = j; - position[b] = k; - if (!this.level.get_block(position[0], position[1], position[2])) { - continue; - } - if (distance > 0 && i >= max[axis] - EPSILON) { - distance = Math.min(distance, i - max[axis]); - } else if (distance < 0 && i + 1 <= min[axis] + EPSILON) { - distance = Math.max(distance, i + 1 - min[axis]); - } - } - } - } - return distance; + const collisions = move_body(this, this.gravity, (x, y, z) => this.level.get_block(x, y, z) !== AIR); + this.colliding_x = collisions.x; + this.colliding_y = collisions.y; + this.colliding_z = collisions.z; } } diff --git a/client/entity/item_entity.ts b/client/entity/item_entity.ts new file mode 100644 index 0000000..1080c3d --- /dev/null +++ b/client/entity/item_entity.ts @@ -0,0 +1,83 @@ +import { ItemStack } from "$/common/inventory.ts"; +import type { EntityInfo } from "$/common/protocol.ts"; +import type { ClientLevel } from "../level/client_level.ts"; +import { Entity } from "./entity.ts"; + +// how many ticks a server position takes to reach, like minecraft's lerpTo +const LERP_TICKS = 3; +// how many ticks it takes to fly into whoever picked it up +const PICKUP_TICKS = 3; + +// an item lying on the ground. the server simulates it, this only moves where it's told and spins +export class ItemEntity extends Entity { + item: ItemStack; + // ticks since it showed up, for spinning and bobbing + age = 0; + // so items dropped together don't spin in step + readonly bob_offset = Math.random() * Math.PI * 2; + + #target_x: number; + #target_y: number; + #target_z: number; + #lerp_ticks = 0; + + // who's picking it up, and for how many ticks it has been flying to them + #picked_up_by: Entity | undefined; + #pickup_age = 0; + + constructor(level: ClientLevel, info: EntityInfo) { + super(level, info.id, 0.25, 0.25, 0.125); + this.item = ItemStack.from_data(info.item); + this.set_position(info.x, info.y, info.z); + this.#target_x = info.x; + this.#target_y = info.y; + this.#target_z = info.z; + } + + lerp_to(x: number, y: number, z: number) { + this.#target_x = x; + this.#target_y = y; + this.#target_z = z; + this.#lerp_ticks = LERP_TICKS; + } + + // it already went into their inventory on the server, this is only the animation + pick_up(by: Entity) { + this.#picked_up_by = by; + } + + tick() { + this.age += 1; + + if (this.#picked_up_by) { + this.#pickup_age += 1; + if (this.#pickup_age >= PICKUP_TICKS) { + this.level.remove_entity(this.id); + } + return; + } + + if (this.#lerp_ticks > 0) { + this.x += (this.#target_x - this.x) / this.#lerp_ticks; + this.y += (this.#target_y - this.y) / this.#lerp_ticks; + this.z += (this.#target_z - this.z) / this.#lerp_ticks; + this.#lerp_ticks -= 1; + } + } + + // while being picked up it speeds towards the middle of whoever took it, like minecraft's ItemPickupParticle + override render_position(partial_tick: number) { + const position = super.render_position(partial_tick); + const by = this.#picked_up_by; + if (!by) { + return position; + } + const target = by.render_position(partial_tick); + const t = Math.min(1, (this.#pickup_age + partial_tick) / PICKUP_TICKS) ** 2; + return { + x: position.x + (target.x - position.x) * t, + y: position.y + (target.y + 0.5 - position.y) * t, + z: position.z + (target.z - position.z) * t, + }; + } +} diff --git a/client/entity/player.ts b/client/entity/player.ts index 920159d..36bf6f2 100644 --- a/client/entity/player.ts +++ b/client/entity/player.ts @@ -1,3 +1,4 @@ +import { PLAYER_EYE_HEIGHT, PLAYER_HEIGHT, PLAYER_WIDTH } from "$/common/constants.ts"; import type { ClientLevel } from "../level/client_level.ts"; import { Entity } from "./entity.ts"; @@ -5,7 +6,7 @@ export abstract class Player extends Entity { name: string; constructor(level: ClientLevel, id: string, name: string) { - super(level, id, 0.55, 1.79, 1.69); + super(level, id, PLAYER_WIDTH, PLAYER_HEIGHT, PLAYER_EYE_HEIGHT); this.name = name; } } diff --git a/client/network.ts b/client/network.ts index 1b62df1..903cc9f 100644 --- a/client/network.ts +++ b/client/network.ts @@ -1,4 +1,4 @@ -import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts"; +import { BlockChange, ClientMessage, EntityInfo, PlayerInfo, ServerMessage } from "$/common/protocol.ts"; import type { ModListing } from "$/common/mod_loader.ts"; import type { Join, ServerSocket, Welcome } from "./handshake.ts"; @@ -13,6 +13,7 @@ export class Connection { selected_slot: number; // who was already there when we joined, they become entities in the level initial_players: PlayerInfo[]; + initial_entities: EntityInfo[]; constructor(server: ServerSocket, welcome: Welcome, join: Join) { this.#server = server; @@ -24,6 +25,7 @@ export class Connection { this.spawn = join.spawn; this.selected_slot = join.selected_slot; this.initial_players = join.players; + this.initial_entities = join.entities; } // handled by the packet listener inside the game loop, not whenever the socket feels like it diff --git a/client/packet_listener.ts b/client/packet_listener.ts index 03e124e..d3fa568 100644 --- a/client/packet_listener.ts +++ b/client/packet_listener.ts @@ -3,6 +3,7 @@ import { Container, ItemStack } from "$/common/inventory.ts"; import type { Client } from "./client.ts"; import { GuiContainer } from "./gui/gui_container.ts"; import { RemotePlayer } from "./entity/remote_player.ts"; +import { ItemEntity } from "./entity/item_entity.ts"; // applies what the server sends, like minecraft's ClientPacketListener. messages queue up on the connection and // get handled here once per frame, inside the game loop @@ -40,6 +41,36 @@ export class ClientPacketListener { } break; } + case "add_entity": + level.add_entity(new ItemEntity(level, message.entity)); + break; + case "move_entity": { + const entity = level.entities.get(message.id); + if (entity instanceof ItemEntity) { + entity.lerp_to(message.x, message.y, message.z); + } + break; + } + case "set_entity_item": { + const entity = level.entities.get(message.id); + if (entity instanceof ItemEntity) { + entity.item = ItemStack.from_data(message.item); + } + break; + } + case "remove_entity": + level.remove_entity(message.id); + break; + case "take_entity": { + const entity = level.entities.get(message.id); + const taker = level.entities.get(message.player); + if (entity instanceof ItemEntity && taker) { + entity.pick_up(taker); + } else { + level.remove_entity(message.id); + } + break; + } case "set_block": level.record_change(message.x, message.y, message.z, message.id, message.state); level.apply_change(message.x, message.y, message.z, message.id, message.state); diff --git a/client/rendering/item_renderer.ts b/client/rendering/item_renderer.ts new file mode 100644 index 0000000..e756997 --- /dev/null +++ b/client/rendering/item_renderer.ts @@ -0,0 +1,141 @@ +import { TEXTURE_SIZE } from "$/common/constants.ts"; +import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import { AssetManager } from "$/client/assets.ts"; +import { get_sprite_region } from "$/client/sprites.ts"; +import type { ItemEntity } from "$/client/entity/item_entity.ts"; +import { push_vertex, Texture } from "$/client/renderer/mod.ts"; + +// how big items on the ground are drawn, minecraft's ground transform +const BLOCK_SCALE = 0.25; +const ITEM_SCALE = 0.5; +// keeps texture lookups off the sprite's edge +const UV_PAD = 0.5; + +// each face's corners in drawing order (counter clockwise from outside) on a unit cube, with its shade. +// top, bottom, front, back, left, right, like the chunk mesher +const CUBE_FACES = [ + { corners: [[0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0]], shade: 1.0, texture: "top" }, + { corners: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], shade: 0.5, texture: "bottom" }, + { corners: [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], shade: 0.8, texture: "front" }, + { corners: [[1, 0, 0], [0, 0, 0], [0, 1, 0], [1, 1, 0]], shade: 0.8, texture: "side" }, + { corners: [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], shade: 0.6, texture: "side" }, + { corners: [[1, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]], shade: 0.6, texture: "side" }, +] as const; +// sprite end each corner gets, u then v +const CORNER_UVS = [[0, 1], [1, 1], [1, 0], [0, 0]] as const; + +// where the extra copies of a bigger stack sit, as fractions of the model's size +const COPY_OFFSETS = [[0, 0, 0], [0.35, 0.2, -0.25], [-0.3, 0.4, 0.2], [0.15, 0.6, 0.35], [-0.2, 0.8, -0.3]]; + +// minecraft draws more copies of the model the bigger the stack is +function copies(amount: number) { + if (amount > 48) return 5; + if (amount > 32) return 4; + if (amount > 16) return 3; + if (amount > 1) return 2; + return 1; +} + +// the atlas has to be the current texture +export function render_item_entity(entity: ItemEntity, partial_tick: number) { + const atlas = AssetManager.instance.get("bworld:textures"); + const item_info = EverythingRegistry.get("items", entity.item.type_id); + const block_info = item_info?.block_id + ? EverythingRegistry.get("blocks", item_info.block_id) + : undefined; + + // spinning and bobbing, like minecraft's ItemEntityRenderer + const time = entity.age + partial_tick; + const angle = time / 20 + entity.bob_offset; + const bob = Math.sin(time / 10 + entity.bob_offset) * 0.1 + 0.1; + const { x, y, z } = entity.render_position(partial_tick); + + const scale = block_info ? BLOCK_SCALE : ITEM_SCALE; + for (let i = 0; i < copies(entity.item.amount); i++) { + const [ox, oy, oz] = COPY_OFFSETS[i]; + const base = { x: x + ox * scale, y: y + bob + oy * scale * 0.5, z: z + oz * scale }; + if (block_info) { + push_block(atlas, block_info, base, scale, angle); + } else { + push_sprite(atlas, item_texture(entity, item_info), base, scale, angle); + } + } +} + +// a small cube turned around its middle +function push_block( + atlas: Texture, + block: BlockRegistry, + base: { x: number; y: number; z: number }, + size: number, + angle: number, +) { + const sin = Math.sin(angle); + const cos = Math.cos(angle); + const corner = (cx: number, cy: number, cz: number) => { + const lx = (cx - 0.5) * size; + const lz = (cz - 0.5) * size; + return [base.x + lx * cos - lz * sin, base.y + cy * size, base.z + lx * sin + lz * cos]; + }; + + for (const face of CUBE_FACES) { + const region = get_sprite_region(block_face_texture(block, face.texture)); + push_textured_quad(atlas, region, face.corners.map(([cx, cy, cz]) => corner(cx, cy, cz)), face.shade); + } +} + +// a flat sprite standing up and turning, drawn from both sides +function push_sprite( + atlas: Texture, + texture_id: string, + base: { x: number; y: number; z: number }, + size: number, + angle: number, +) { + const region = get_sprite_region(texture_id); + const dx = Math.cos(angle) * size / 2; + const dz = Math.sin(angle) * size / 2; + const bottom = base.y; + const top = base.y + size; + const front = [ + [base.x - dx, bottom, base.z - dz], + [base.x + dx, bottom, base.z + dz], + [base.x + dx, top, base.z + dz], + [base.x - dx, top, base.z - dz], + ]; + push_textured_quad(atlas, region, front, 1); + // the back is the same quad the other way round, mirrored so it isn't drawn backwards + push_textured_quad(atlas, region, [front[1], front[0], front[3], front[2]], 1); +} + +function push_textured_quad(atlas: Texture, region: { x: number; y: number }, corners: number[][], shade: number) { + const u0 = (region.x * TEXTURE_SIZE + UV_PAD) / atlas.width; + const v0 = (region.y * TEXTURE_SIZE + UV_PAD) / atlas.height; + const u1 = ((region.x + 1) * TEXTURE_SIZE - UV_PAD) / atlas.width; + const v1 = ((region.y + 1) * TEXTURE_SIZE - UV_PAD) / atlas.height; + + for (const i of [0, 1, 2, 0, 2, 3]) { + const [px, py, pz] = corners[i]; + const [cu, cv] = CORNER_UVS[i]; + push_vertex(px, py, pz, cu ? u1 : u0, cv ? v1 : v0, shade, shade, shade, 1); + } +} + +function block_face_texture(block: BlockRegistry, face: "top" | "bottom" | "front" | "side") { + const textures = block.textures; + if (typeof textures === "string") { + return textures; + } + if ("top" in textures) { + return face === "top" ? textures.top : face === "bottom" ? textures.bottom : textures.side; + } + return face === "front" ? textures.front : textures.side; +} + +function item_texture(entity: ItemEntity, item_info: ItemRegistry | undefined) { + const texture_id = item_info?.texture_id; + if (typeof texture_id === "function") { + return texture_id(entity.item); + } + return texture_id ?? "engine:missing"; +} diff --git a/client/rendering/level_renderer.ts b/client/rendering/level_renderer.ts index 0f3531a..9cb7ef5 100644 --- a/client/rendering/level_renderer.ts +++ b/client/rendering/level_renderer.ts @@ -6,6 +6,8 @@ import { get_sprite_region } from "$/client/sprites.ts"; import type { MultiPlayerGameMode } from "$/client/game_mode.ts"; import type { Entity } from "$/client/entity/entity.ts"; import { RemotePlayer } from "$/client/entity/remote_player.ts"; +import { ItemEntity } from "$/client/entity/item_entity.ts"; +import { render_item_entity } from "./item_renderer.ts"; import { draw_terrain, flush_batch, @@ -84,7 +86,14 @@ export class LevelRenderer { render_player(entity, partial_tick); } } + flush_batch(); + set_current_texture(level.image.tex); + for (const entity of level.entities.values()) { + if (entity instanceof ItemEntity) { + render_item_entity(entity, partial_tick); + } + } flush_batch(); } diff --git a/common/constants.ts b/common/constants.ts index 290d02e..c59ab77 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -23,6 +23,11 @@ export const CHUNK_SIZE = 16; export const CHUNK_HEIGHT = 128; export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; +// the player's collision box, position is the middle of its feet +export const PLAYER_WIDTH = 0.55; +export const PLAYER_HEIGHT = 1.79; +export const PLAYER_EYE_HEIGHT = 1.69; + // where a block placed against each face of another block goes export const FACE_OFFSETS: Record = { top: { x: 0, y: 1, z: 0 }, diff --git a/common/mod_api/server.ts b/common/mod_api/server.ts index e16db92..88af2f6 100644 --- a/common/mod_api/server.ts +++ b/common/mod_api/server.ts @@ -134,6 +134,7 @@ export interface Player { readonly inventory: Container; // 36 slots, hotbar is 0-8 readonly selected_slot: number; readonly held_item: ItemStack | undefined; + // what doesn't fit in the inventory drops at their feet give_item(id: Id, count?: number, data?: unknown): void; send_message(text: string): void; teleport(x: number, y: number, z: number): void; diff --git a/common/physics.ts b/common/physics.ts new file mode 100644 index 0000000..ea665e1 --- /dev/null +++ b/common/physics.ts @@ -0,0 +1,96 @@ +// collision against blocks, shared by the client's entities and the server's, so both move things the same way +import { TICK_DELTA } from "./constants.ts"; + +// how far inside a block face still counts as touching it, so boxes resting exactly on a face don't snag +const EPSILON = 1e-7; +// the two axes that aren't the one being moved along +const OTHER_AXES = [[1, 2], [0, 2], [0, 1]] as const; + +// something with a box that moves. position is the middle of its feet, velocity is in blocks per second +export interface Body { + x: number; + y: number; + z: number; + vx: number; + vy: number; + vz: number; + // width is used for both x and z + width: number; + height: number; +} + +// which way the body hit something on each axis: 1 or -1, 0 for nothing. 1 on y means it landed on something +export interface Collisions { + x: number; + y: number; + z: number; +} + +// falls and moves a body by its velocity for one tick. like minecraft it moves along y, then x, then z, each +// time only as far as it can before touching a block, and stops its velocity on the axes it hit something. +// blocks it's already inside don't stop it, so it can get out of them +export function move_body(body: Body, gravity: number, is_solid: (x: number, y: number, z: number) => boolean) { + // the average of this tick's start and end speed, so the arc is the same at any tick rate + const wanted_y = (body.vy + gravity * TICK_DELTA / 2) * TICK_DELTA; + body.vy += gravity * TICK_DELTA; + const wanted = [body.vx * TICK_DELTA, wanted_y, body.vz * TICK_DELTA]; + + const half = body.width / 2; + const min = [body.x - half, body.y, body.z - half]; + const max = [body.x + half, body.y + body.height, body.z + half]; + const moved = [0, 0, 0]; + for (const axis of [1, 0, 2]) { + const distance = clip(min, max, axis, wanted[axis], is_solid); + min[axis] += distance; + max[axis] += distance; + moved[axis] = distance; + } + + const hit = (axis: number) => moved[axis] !== wanted[axis] ? -Math.sign(wanted[axis]) : 0; + const collisions: Collisions = { x: hit(0), y: hit(1), z: hit(2) }; + if (collisions.x !== 0) body.vx = 0; + if (collisions.y !== 0) body.vy = 0; + if (collisions.z !== 0) body.vz = 0; + + body.x += moved[0]; + body.y += moved[1]; + body.z += moved[2]; + return collisions; +} + +// how far the box can go along an axis before it runs into a block +function clip( + min: number[], + max: number[], + axis: number, + distance: number, + is_solid: (x: number, y: number, z: number) => boolean, +) { + if (distance === 0) { + return 0; + } + + const [a, b] = OTHER_AXES[axis]; + const from = Math.floor(Math.min(min[axis], min[axis] + distance)); + const to = Math.floor(Math.max(max[axis], max[axis] + distance)); + const position = [0, 0, 0]; + + for (let i = from; i <= to; i++) { + for (let j = Math.floor(min[a] + EPSILON); j <= Math.floor(max[a] - EPSILON); j++) { + for (let k = Math.floor(min[b] + EPSILON); k <= Math.floor(max[b] - EPSILON); k++) { + position[axis] = i; + position[a] = j; + position[b] = k; + if (!is_solid(position[0], position[1], position[2])) { + continue; + } + if (distance > 0 && i >= max[axis] - EPSILON) { + distance = Math.min(distance, i - max[axis]); + } else if (distance < 0 && i + 1 <= min[axis] + EPSILON) { + distance = Math.max(distance, i + 1 - min[axis]); + } + } + } + } + return distance; +} diff --git a/common/protocol.ts b/common/protocol.ts index 0a1c62f..90ae1f2 100644 --- a/common/protocol.ts +++ b/common/protocol.ts @@ -7,7 +7,7 @@ export const AIR_ID = "bworld:air"; // bump when a client and server of different versions can't play together. // the server rejects a different version before the client downloads anything -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; export interface PlayerInfo { id: string; @@ -19,6 +19,16 @@ export interface PlayerInfo { pitch: number; } +// an entity that isn't a player, as the server first sends it. players have their own messages +export interface EntityInfo { + kind: "item"; + id: string; + x: number; + y: number; + z: number; + item: ItemData; +} + // x, y, z, block id, and its state bits when they aren't 0 export type BlockChange = [number, number, number, string, number?]; @@ -84,6 +94,8 @@ export type ServerMessage = // the server may change the name asked for, like alice to alice2 name: string; players: PlayerInfo[]; + // items on the ground + entities: EntityInfo[]; changes: BlockChange[]; spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; selected_slot: number; @@ -91,6 +103,13 @@ export type ServerMessage = | { type: "player_join"; player: PlayerInfo } | { type: "player_leave"; id: string } | { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number } + | { type: "add_entity"; entity: EntityInfo } + | { type: "move_entity"; id: string; x: number; y: number; z: number } + // a dropped item's stack changed, like when two stacks merge + | { type: "set_entity_item"; id: string; item: ItemData } + | { type: "remove_entity"; id: string } + // a player picked up an item, it flies to them and goes away + | { type: "take_entity"; id: string; player: string } // also sent to the player who caused it, which corrects anything their client predicted wrong | { type: "set_block"; x: number; y: number; z: number; id: string; state?: number } | { type: "chat"; from?: string; text: string } diff --git a/server/game/blocks.ts b/server/game/blocks.ts index 6777018..ae58861 100644 --- a/server/game/blocks.ts +++ b/server/game/blocks.ts @@ -23,16 +23,14 @@ export interface BlockBehavior { export const BLOCK_BEHAVIORS: Record = {}; -// 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; diff --git a/server/game/game_server.ts b/server/game/game_server.ts index de2a349..886883f 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -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; + // items on the ground + entities?: SavedItemEntity[]; // ctx.storage of every mod, by mod id mod_storage?: Record>; } @@ -81,6 +98,9 @@ export class GameServer { #pending = new Map(); #saved_players: Record = {}; #tick = 0; + // items on the ground, by id + #entities = new Map(); + #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; } diff --git a/server/game/item_entity.ts b/server/game/item_entity.ts new file mode 100644 index 0000000..dbf5e55 --- /dev/null +++ b/server/game/item_entity.ts @@ -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; + } +} diff --git a/server/game/player.ts b/server/game/player.ts index 154ae93..a636790 100644 --- a/server/game/player.ts +++ b/server/game/player.ts @@ -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 }; } diff --git a/tests/item_entity_test.ts b/tests/item_entity_test.ts new file mode 100644 index 0000000..5124d23 --- /dev/null +++ b/tests/item_entity_test.ts @@ -0,0 +1,87 @@ +import { assert, assertEquals } from "@std/assert"; +import { ItemStack } from "$/common/inventory.ts"; +import { test_game } from "./helpers.ts"; + +// a stone floor at y 99 so drops have something to land on, high above the generated terrain +function build_floor(game: Awaited>["game"]) { + for (let x = -3; x <= 9; x++) { + for (let z = -4; z <= 4; z++) { + game.set_block(x, 99, z, "bworld:stone"); + } + } +} + +const inventory_count = ( + messages: { type: string; container?: string; items?: ({ id: string; count: number } | null)[] }[], + id: string, +) => (messages.filter((m) => m.type === "container" && m.container === "inventory").at(-1)?.items ?? []) + .reduce((sum, item) => sum + (item?.id === id ? item.count : 0), 0); + +Deno.test("breaking a block drops its item, which lands and gets picked up by walking to it", async () => { + const { game, take, send, join } = await test_game("mods"); + join(1, "alice"); + build_floor(game); + game.set_block(5, 100, 0, "bworld:dirt"); + send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 }); + take(1); + + send(1, { type: "break_block", x: 5, y: 100, z: 0 }); + const messages = take(1); + const added = messages.find((m) => m.type === "add_entity"); + assertEquals(added?.entity.kind, "item"); + assertEquals(added?.entity.item, { id: "bworld:dirt", count: 1 }); + assertEquals(inventory_count(messages, "bworld:dirt"), 0, "it drops instead of going into the inventory"); + + for (let i = 0; i < 40; i++) game.tick(); + const [item] = game.item_entities(); + assert(item.on_ground, "it fell onto the floor"); + assertEquals(item.y, 100); + assert(Math.abs(item.x - 5.5) < 1.5 && Math.abs(item.z - 0.5) < 1.5, "it stays near the block"); + assert(take(1).some((m) => m.type === "move_entity" && m.id === item.id)); + + send(1, { type: "move", x: item.x, y: 100, z: item.z, yaw: 0, pitch: 0 }); + game.tick(); + const picked = take(1); + assert(picked.some((m) => m.type === "take_entity" && m.id === item.id)); + assertEquals(inventory_count(picked, "bworld:dirt"), 1); + assertEquals(game.item_entities(), []); +}); + +Deno.test("items that don't fit in the inventory drop at the player's feet and stay there", async () => { + const { game, take, send, join } = await test_game("mods"); + join(1, "alice"); + build_floor(game); + send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 }); + + // a full inventory, then 10 more + send(1, { type: "chat", text: `/give bworld:stone ${64 * 36}` }); + assertEquals(game.item_entities(), []); + send(1, { type: "chat", text: "/give bworld:stone 10" }); + const [item] = game.item_entities(); + assertEquals(item.item.amount, 10); + + for (let i = 0; i < 60; i++) game.tick(); + assertEquals(game.item_entities().length, 1, "a full inventory can't pick it up"); + assert(take(1).some((m) => m.type === "add_entity")); +}); + +Deno.test("stacks next to each other merge, and items on the ground are saved with the world", async () => { + const { game, join } = await test_game("mods"); + join(1, "alice"); + build_floor(game); + for (let i = 0; i < 3; i++) { + game.spawn_item(6.5, 100, 0.5, new ItemStack("bworld:dirt", 2)); + } + game.tick(); + game.tick(); + assertEquals(game.item_entities().map((entity) => entity.item.amount), [6]); + + const saved = game.save(); + const reloaded = await test_game("mods", saved); + assertEquals(reloaded.game.item_entities().map((entity) => [entity.item.type_id, entity.item.amount]), [[ + "bworld:dirt", + 6, + ]]); + const joined = reloaded.join(2, "bob"); + assertEquals(joined.entities.map((entity: { item: unknown }) => entity.item), [{ id: "bworld:dirt", count: 6 }]); +});