Item entities
This commit is contained in:
@@ -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);
|
||||
|
||||
+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";
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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;
|
||||
}
|
||||
}
|
||||
|
||||
+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 { 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { 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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user