Item entities
This commit is contained in:
@@ -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.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.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`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 inventory: Container; // 36 slots, hotbar is 0-8
|
||||||
readonly selected_slot: number;
|
readonly selected_slot: number;
|
||||||
readonly held_item: ItemStack | undefined;
|
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;
|
give_item(id: string, count?: number, data?: unknown): void;
|
||||||
send_message(text: string): void;
|
send_message(text: string): void;
|
||||||
teleport(x: number, y: number, z: number): void;
|
teleport(x: number, y: number, z: number): void;
|
||||||
@@ -852,8 +853,8 @@ client server
|
|||||||
│ │
|
│ │
|
||||||
│ ready │
|
│ ready │
|
||||||
├────────────────────────────────────────►│
|
├────────────────────────────────────────►│
|
||||||
│ join { id, name, players, changes, │ the player is created here, player_join fires,
|
│ join { id, name, players, entities, │ the player is created here, player_join fires,
|
||||||
│ spawn, selected_slot } │ and their inventory follows as container messages
|
│ changes, spawn, selected_slot } │ and their inventory follows as container messages
|
||||||
│◄────────────────────────────────────────┤
|
│◄────────────────────────────────────────┤
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ClientLevel } from "./level/client_level.ts";
|
|||||||
import type { BlockHitResult } from "./level/client_level.ts";
|
import type { BlockHitResult } from "./level/client_level.ts";
|
||||||
import { LocalPlayer } from "./entity/local_player.ts";
|
import { LocalPlayer } from "./entity/local_player.ts";
|
||||||
import { RemotePlayer } from "./entity/remote_player.ts";
|
import { RemotePlayer } from "./entity/remote_player.ts";
|
||||||
|
import { ItemEntity } from "./entity/item_entity.ts";
|
||||||
import { Camera } from "./camera.ts";
|
import { Camera } from "./camera.ts";
|
||||||
import { Options } from "./options.ts";
|
import { Options } from "./options.ts";
|
||||||
import { MultiPlayerGameMode } from "./game_mode.ts";
|
import { MultiPlayerGameMode } from "./game_mode.ts";
|
||||||
@@ -67,6 +68,9 @@ export class Client {
|
|||||||
for (const info of connection.initial_players) {
|
for (const info of connection.initial_players) {
|
||||||
this.level.add_entity(new RemotePlayer(this.level, info));
|
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.game_mode = new MultiPlayerGameMode(this);
|
||||||
this.packet_listener = new ClientPacketListener(this);
|
this.packet_listener = new ClientPacketListener(this);
|
||||||
|
|||||||
+7
-67
@@ -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";
|
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.
|
// 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
|
// it's simulated in fixed ticks (common/constants.ts), frames draw it between its last two positions
|
||||||
export abstract class Entity {
|
export abstract class Entity {
|
||||||
@@ -75,67 +71,11 @@ export abstract class Entity {
|
|||||||
// one step of the game, TICK_DELTA seconds
|
// one step of the game, TICK_DELTA seconds
|
||||||
abstract tick(): void;
|
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
|
// falls and moves by its velocity for one tick, see move_body
|
||||||
// as far as it can before touching a block, and stops its velocity on the axes it hit something
|
|
||||||
move() {
|
move() {
|
||||||
// the average of this tick's start and end speed, so the arc is the same at any tick rate
|
const collisions = move_body(this, this.gravity, (x, y, z) => this.level.get_block(x, y, z) !== AIR);
|
||||||
const wanted_y = (this.vy + this.gravity * TICK_DELTA / 2) * TICK_DELTA;
|
this.colliding_x = collisions.x;
|
||||||
this.vy += this.gravity * TICK_DELTA;
|
this.colliding_y = collisions.y;
|
||||||
const wanted = [this.vx * TICK_DELTA, wanted_y, this.vz * TICK_DELTA];
|
this.colliding_z = collisions.z;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 type { ClientLevel } from "../level/client_level.ts";
|
||||||
import { Entity } from "./entity.ts";
|
import { Entity } from "./entity.ts";
|
||||||
|
|
||||||
@@ -5,7 +6,7 @@ export abstract class Player extends Entity {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
constructor(level: ClientLevel, id: string, 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;
|
this.name = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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 { ModListing } from "$/common/mod_loader.ts";
|
||||||
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
|
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ export class Connection {
|
|||||||
selected_slot: number;
|
selected_slot: number;
|
||||||
// who was already there when we joined, they become entities in the level
|
// who was already there when we joined, they become entities in the level
|
||||||
initial_players: PlayerInfo[];
|
initial_players: PlayerInfo[];
|
||||||
|
initial_entities: EntityInfo[];
|
||||||
|
|
||||||
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
|
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
|
||||||
this.#server = server;
|
this.#server = server;
|
||||||
@@ -24,6 +25,7 @@ export class Connection {
|
|||||||
this.spawn = join.spawn;
|
this.spawn = join.spawn;
|
||||||
this.selected_slot = join.selected_slot;
|
this.selected_slot = join.selected_slot;
|
||||||
this.initial_players = join.players;
|
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
|
// handled by the packet listener inside the game loop, not whenever the socket feels like it
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Container, ItemStack } from "$/common/inventory.ts";
|
|||||||
import type { Client } from "./client.ts";
|
import type { Client } from "./client.ts";
|
||||||
import { GuiContainer } from "./gui/gui_container.ts";
|
import { GuiContainer } from "./gui/gui_container.ts";
|
||||||
import { RemotePlayer } from "./entity/remote_player.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
|
// 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
|
// get handled here once per frame, inside the game loop
|
||||||
@@ -40,6 +41,36 @@ export class ClientPacketListener {
|
|||||||
}
|
}
|
||||||
break;
|
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":
|
case "set_block":
|
||||||
level.record_change(message.x, message.y, message.z, message.id, message.state);
|
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);
|
level.apply_change(message.x, message.y, message.z, message.id, message.state);
|
||||||
|
|||||||
@@ -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<Texture>("bworld:textures");
|
||||||
|
const item_info = EverythingRegistry.get<ItemRegistry>("items", entity.item.type_id);
|
||||||
|
const block_info = item_info?.block_id
|
||||||
|
? EverythingRegistry.get<BlockRegistry>("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";
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { get_sprite_region } from "$/client/sprites.ts";
|
|||||||
import type { MultiPlayerGameMode } from "$/client/game_mode.ts";
|
import type { MultiPlayerGameMode } from "$/client/game_mode.ts";
|
||||||
import type { Entity } from "$/client/entity/entity.ts";
|
import type { Entity } from "$/client/entity/entity.ts";
|
||||||
import { RemotePlayer } from "$/client/entity/remote_player.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 {
|
import {
|
||||||
draw_terrain,
|
draw_terrain,
|
||||||
flush_batch,
|
flush_batch,
|
||||||
@@ -84,7 +86,14 @@ export class LevelRenderer {
|
|||||||
render_player(entity, partial_tick);
|
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();
|
flush_batch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export const CHUNK_SIZE = 16;
|
|||||||
export const CHUNK_HEIGHT = 128;
|
export const CHUNK_HEIGHT = 128;
|
||||||
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
|
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
|
// where a block placed against each face of another block goes
|
||||||
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
|
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
|
||||||
top: { x: 0, y: 1, z: 0 },
|
top: { x: 0, y: 1, z: 0 },
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface Player {
|
|||||||
readonly inventory: Container; // 36 slots, hotbar is 0-8
|
readonly inventory: Container; // 36 slots, hotbar is 0-8
|
||||||
readonly selected_slot: number;
|
readonly selected_slot: number;
|
||||||
readonly held_item: ItemStack | undefined;
|
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;
|
give_item(id: Id, count?: number, data?: unknown): void;
|
||||||
send_message(text: string): void;
|
send_message(text: string): void;
|
||||||
teleport(x: number, y: number, z: number): void;
|
teleport(x: number, y: number, z: number): void;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+20
-1
@@ -7,7 +7,7 @@ export const AIR_ID = "bworld:air";
|
|||||||
|
|
||||||
// bump when a client and server of different versions can't play together.
|
// bump when a client and server of different versions can't play together.
|
||||||
// the server rejects a different version before the client downloads anything
|
// the server rejects a different version before the client downloads anything
|
||||||
export const PROTOCOL_VERSION = 1;
|
export const PROTOCOL_VERSION = 2;
|
||||||
|
|
||||||
export interface PlayerInfo {
|
export interface PlayerInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +19,16 @@ export interface PlayerInfo {
|
|||||||
pitch: number;
|
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
|
// x, y, z, block id, and its state bits when they aren't 0
|
||||||
export type BlockChange = [number, number, number, string, number?];
|
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
|
// the server may change the name asked for, like alice to alice2
|
||||||
name: string;
|
name: string;
|
||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
|
// items on the ground
|
||||||
|
entities: EntityInfo[];
|
||||||
changes: BlockChange[];
|
changes: BlockChange[];
|
||||||
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||||
selected_slot: number;
|
selected_slot: number;
|
||||||
@@ -91,6 +103,13 @@ export type ServerMessage =
|
|||||||
| { type: "player_join"; player: PlayerInfo }
|
| { type: "player_join"; player: PlayerInfo }
|
||||||
| { type: "player_leave"; id: string }
|
| { type: "player_leave"; id: string }
|
||||||
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number }
|
| { 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
|
// 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: "set_block"; x: number; y: number; z: number; id: string; state?: number }
|
||||||
| { type: "chat"; from?: string; text: string }
|
| { type: "chat"; from?: string; text: string }
|
||||||
|
|||||||
+8
-10
@@ -23,16 +23,14 @@ export interface BlockBehavior {
|
|||||||
|
|
||||||
export const BLOCK_BEHAVIORS: Record<string, BlockBehavior> = {};
|
export const BLOCK_BEHAVIORS: Record<string, BlockBehavior> = {};
|
||||||
|
|
||||||
// give the breaking player whatever was inside
|
// whatever was inside falls out, like minecraft's Containers.dropContents
|
||||||
function give_container_contents(tile: Tile, player: ServerPlayer | undefined) {
|
function drop_container_contents(game: GameServer, tile: Tile) {
|
||||||
if (!player) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const container of Object.values(tile.containers)) {
|
for (const container of Object.values(tile.containers)) {
|
||||||
for (let i = 0; i < container.size; i++) {
|
for (let i = 0; i < container.size; i++) {
|
||||||
const item = container.get_item(i);
|
const item = container.get_item(i);
|
||||||
if (item) {
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
on_break(_game, tile, player) {
|
on_break(game, tile) {
|
||||||
give_container_contents(tile, player);
|
drop_container_contents(game, tile);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,8 +199,8 @@ BLOCK_BEHAVIORS["bworld:furnace"] = {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
on_break(_game, tile, player) {
|
on_break(game, tile) {
|
||||||
give_container_contents(tile, player);
|
drop_container_contents(game, tile);
|
||||||
},
|
},
|
||||||
on_tick(game, tile) {
|
on_tick(game, tile) {
|
||||||
const data = tile.data as unknown as FurnaceData;
|
const data = tile.data as unknown as FurnaceData;
|
||||||
|
|||||||
+173
-6
@@ -5,6 +5,9 @@ import {
|
|||||||
FACE_OFFSETS,
|
FACE_OFFSETS,
|
||||||
Faces,
|
Faces,
|
||||||
faces,
|
faces,
|
||||||
|
PLAYER_EYE_HEIGHT,
|
||||||
|
PLAYER_HEIGHT,
|
||||||
|
PLAYER_WIDTH,
|
||||||
TICK_DELTA,
|
TICK_DELTA,
|
||||||
TICKS_PER_SECOND,
|
TICKS_PER_SECOND,
|
||||||
} from "$/common/constants.ts";
|
} 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 { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||||
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts";
|
||||||
import { ServerWorld, Tile } from "./world.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
|
// 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 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
|
// tiles further than this many chunks from every player don't tick
|
||||||
const SIMULATION_DISTANCE = 6;
|
const SIMULATION_DISTANCE = 6;
|
||||||
const MAX_GIVE = 64 * 36;
|
const MAX_GIVE = 64 * 36;
|
||||||
@@ -60,6 +75,8 @@ export interface SavedWorld {
|
|||||||
mod_data?: unknown;
|
mod_data?: unknown;
|
||||||
}[];
|
}[];
|
||||||
players: Record<string, SavedPlayer>;
|
players: Record<string, SavedPlayer>;
|
||||||
|
// items on the ground
|
||||||
|
entities?: SavedItemEntity[];
|
||||||
// ctx.storage of every mod, by mod id
|
// ctx.storage of every mod, by mod id
|
||||||
mod_storage?: Record<string, Record<string, unknown>>;
|
mod_storage?: Record<string, Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
@@ -81,6 +98,9 @@ export class GameServer {
|
|||||||
#pending = new Map<number, { name: unknown; since: number }>();
|
#pending = new Map<number, { name: unknown; since: number }>();
|
||||||
#saved_players: Record<string, SavedPlayer> = {};
|
#saved_players: Record<string, SavedPlayer> = {};
|
||||||
#tick = 0;
|
#tick = 0;
|
||||||
|
// items on the ground, by id
|
||||||
|
#entities = new Map<string, ItemEntity>();
|
||||||
|
#next_entity_id = 1;
|
||||||
#mods: GameMods;
|
#mods: GameMods;
|
||||||
mods: ModRuntime;
|
mods: ModRuntime;
|
||||||
recipes: RecipeBook;
|
recipes: RecipeBook;
|
||||||
@@ -106,6 +126,10 @@ export class GameServer {
|
|||||||
}
|
}
|
||||||
this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers });
|
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.#saved_players = saved?.players ?? {};
|
||||||
this.world.dirty = saved?.version !== 2;
|
this.world.dirty = saved?.version !== 2;
|
||||||
|
|
||||||
@@ -189,6 +213,8 @@ export class GameServer {
|
|||||||
this.world.dirty = true;
|
this.world.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#tick_items();
|
||||||
|
|
||||||
this.mods.tick();
|
this.mods.tick();
|
||||||
this.mods.check_watched_containers();
|
this.mods.check_watched_containers();
|
||||||
|
|
||||||
@@ -224,6 +250,7 @@ export class GameServer {
|
|||||||
mod_data: tile.mod_data,
|
mod_data: tile.mod_data,
|
||||||
})),
|
})),
|
||||||
players: this.#saved_players,
|
players: this.#saved_players,
|
||||||
|
entities: [...this.#entities.values()].map((entity) => entity.save()),
|
||||||
mod_storage: this.mods.storage,
|
mod_storage: this.mods.storage,
|
||||||
};
|
};
|
||||||
this.world.dirty = false;
|
this.world.dirty = false;
|
||||||
@@ -248,10 +275,45 @@ export class GameServer {
|
|||||||
stack.amount = Math.min(left, stack.max_amount);
|
stack.amount = Math.min(left, stack.max_amount);
|
||||||
if (data !== undefined) stack.data = structuredClone(data);
|
if (data !== undefined) stack.data = structuredClone(data);
|
||||||
left -= stack.amount;
|
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) {
|
send_chat(player: ServerPlayer, text: string) {
|
||||||
this.#send(player, { type: "chat", text });
|
this.#send(player, { type: "chat", text });
|
||||||
}
|
}
|
||||||
@@ -392,6 +454,7 @@ export class GameServer {
|
|||||||
name: player.name,
|
name: player.name,
|
||||||
players: [...this.#players.values()].map((p) => p.info()),
|
players: [...this.#players.values()].map((p) => p.info()),
|
||||||
changes: this.world.all_changes(),
|
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 },
|
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
|
||||||
selected_slot: player.selected_slot,
|
selected_slot: player.selected_slot,
|
||||||
});
|
});
|
||||||
@@ -475,7 +538,7 @@ export class GameServer {
|
|||||||
|
|
||||||
this.set_block(x, y, z, AIR_ID, player);
|
this.set_block(x, y, z, AIR_ID, player);
|
||||||
if (drops) {
|
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);
|
this.mods.after.block_break.emit(event);
|
||||||
}
|
}
|
||||||
@@ -687,14 +750,118 @@ export class GameServer {
|
|||||||
for (let i = 0; i < 9; i++) {
|
for (let i = 0; i < 9; i++) {
|
||||||
const item = player.crafting.get_item(i);
|
const item = player.crafting.get_item(i);
|
||||||
if (item) {
|
if (item) {
|
||||||
player.give(item);
|
|
||||||
player.crafting.set_item(i, undefined);
|
player.crafting.set_item(i, undefined);
|
||||||
|
this.give_stack(player, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
update_crafting_result(player.crafting, this.recipes.shaped);
|
update_crafting_result(player.crafting, this.recipes.shaped);
|
||||||
if (player.cursor.item) {
|
if (player.cursor.item) {
|
||||||
player.give(player.cursor.item);
|
const item = player.cursor.item;
|
||||||
player.cursor.item = undefined;
|
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) {
|
#in_reach(player: ServerPlayer, x: number, y: number, z: number) {
|
||||||
const dx = x + 0.5 - player.x;
|
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;
|
const dz = z + 0.5 - player.z;
|
||||||
return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH;
|
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);
|
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 {
|
info(): PlayerInfo {
|
||||||
return { id: this.id, name: this.name, x: this.x, y: this.y, z: this.z, yaw: this.yaw, pitch: this.pitch };
|
return { id: this.id, name: this.name, x: this.x, y: this.y, z: this.z, yaw: this.yaw, pitch: this.pitch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ReturnType<typeof test_game>>["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 }]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user