No more ecs

This commit is contained in:
2026-09-25 16:05:25 -03:00
parent 4539898c58
commit 213363d0be
56 changed files with 1103 additions and 1644 deletions
+24
View File
@@ -0,0 +1,24 @@
import type { Entity } from "./entity/entity.ts";
// where the world is drawn from. like minecraft's Camera it isn't an entity, it's moved to one's eyes every frame
export class Camera {
x = 0;
y = 0;
z = 3;
pitch = 0;
yaw = 0;
roll = 0;
fov = Math.PI / 3;
near = 0.1;
far = 1000;
setup(entity: Entity) {
this.x = entity.x;
this.y = entity.y + entity.eye_height;
this.z = entity.z;
this.yaw = entity.yaw;
this.pitch = entity.pitch;
}
}
+177
View File
@@ -0,0 +1,177 @@
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 { Camera } from "./camera.ts";
import { Options } from "./options.ts";
import { MultiPlayerGameMode } from "./game_mode.ts";
import { ClientPacketListener } from "./packet_listener.ts";
import { GameRenderer } from "./rendering/game_renderer.ts";
import { ChatComponent } from "./gui/chat_component.ts";
import { GuiInventoryScreen, GuiScreen } from "./gui/gui_screen.ts";
import { GuiPlayerInventory } from "./gui/gui_player_inventory.ts";
import { GuiChat } from "./gui/gui_chat.ts";
import { InputManager } from "./input_manager.ts";
import { Connection } from "./network.ts";
import { canvas, resize_canvas } from "./renderer/mod.ts";
import { show_fatal_error } from "./fatal.ts";
// the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer,
// and runs one step of the game every frame
export class Client {
connection: Connection;
options = new Options();
level: ClientLevel;
player: LocalPlayer;
camera = new Camera();
game_mode: MultiPlayerGameMode;
packet_listener: ClientPacketListener;
game_renderer = new GameRenderer();
chat = new ChatComponent();
// open screens, the last one is on top and gets the input
screens: GuiScreen[] = [];
// what the player is looking at, updated every frame
hit_result: BlockHitResult | undefined;
debugging = false;
constructor(connection: Connection) {
this.connection = connection;
self.addEventListener("resize", resize_canvas);
resize_canvas();
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
this.level = new ClientLevel(connection.seed);
for (const [x, y, z, id, state] of connection.initial_changes) {
this.level.record_change(x, y, z, id, state);
}
const spawn = connection.spawn;
this.player = new LocalPlayer(this, connection.id, connection.name);
this.player.set_position(spawn.x, spawn.y, spawn.z);
this.player.yaw = spawn.yaw;
this.player.pitch = spawn.pitch;
this.player.inventories.hotbar_selected = connection.selected_slot;
this.level.add_entity(this.player);
for (const info of connection.initial_players) {
this.level.add_entity(new RemotePlayer(this.level, info));
}
this.game_mode = new MultiPlayerGameMode(this);
this.packet_listener = new ClientPacketListener(this);
}
get screen(): GuiScreen | undefined {
return this.screens.at(-1);
}
push_screen(screen: GuiScreen) {
this.screens.push(screen);
}
pop_screen() {
this.screens.pop()?.on_close();
}
// one frame: network, input, the level and its entities, then drawing
run_frame(delta: number) {
this.packet_listener.handle_packets();
if (this.connection.closed) {
show_fatal_error("Lost connection to the server");
return;
}
this.screen?.on_tick(delta);
this.#handle_keybinds(delta);
this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance);
this.level.tick(delta);
this.game_renderer.render(this);
}
#handle_keybinds(delta: number) {
const options = this.options;
const player = this.player;
if (InputManager.is_key_pressed(options.key_inventory)) {
if (!this.screen) {
this.push_screen(new GuiPlayerInventory(player.inventories, (m) => this.connection.send(m)));
} else if (this.screen instanceof GuiInventoryScreen) {
this.pop_screen();
}
}
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
this.push_screen(new GuiChat(this));
}
if (InputManager.is_key_pressed("Escape")) {
this.pop_screen();
}
if (InputManager.is_key_pressed(options.key_debug)) {
this.debugging = !this.debugging;
}
if (InputManager.is_key_pressed(options.key_fullscreen)) {
InputManager.toggle_fullscreen();
}
if (InputManager.is_mouse_grabbed()) {
const mouse = InputManager.get_mouse_delta();
player.turn(mouse.x, mouse.y);
}
InputManager.set_mouse_grabbed(!this.screen);
this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch);
this.#handle_block_interaction(delta);
if (!this.screen) {
this.#handle_hotbar();
}
}
#handle_block_interaction(delta: number) {
const hit = this.hit_result;
const attacking = !this.screen && InputManager.is_mouse_down(0);
if (!attacking || !hit) {
this.game_mode.stop_destroy_block();
}
if (this.screen) {
return;
}
if (hit) {
if (InputManager.is_mouse_pressed(0)) {
this.game_mode.start_destroy_block(hit);
}
if (attacking) {
this.game_mode.continue_destroy_block(hit, delta);
} else if (InputManager.is_mouse_pressed(2)) {
this.game_mode.use_item_on(hit);
}
} else if (InputManager.is_mouse_pressed(2)) {
this.game_mode.use_item();
}
}
#handle_hotbar() {
const inventories = this.player.inventories;
const previous = inventories.hotbar_selected;
const scroll = InputManager.get_wheel_delta();
if (scroll > 0) {
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
} else if (scroll < 0) {
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
}
const pressed = this.options.key_hotbar.findIndex((key) => InputManager.is_key_pressed(key));
if (pressed !== -1) {
inventories.hotbar_selected = pressed;
}
if (inventories.hotbar_selected !== previous) {
this.connection.send({ type: "select_slot", slot: inventories.hotbar_selected });
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import { World } from "$/common/ecs/mod.ts";
import { MovementSystem } from "$/common/systems/movement_system.ts";
import { RenderSystem } from "$/client/systems/render_system.ts";
import { PlayerControlsSystem } from "$/client/systems/player_controls.ts";
import { DebugSystem } from "$/client/systems/debug_system.ts";
import { UIInteractionSystem } from "$/client/systems/ui_interaction_system.ts";
import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
import { create_main_menu } from "./main_menu.ts";
import { start_game } from "./game.ts";
import { canvas, resize_canvas } from "./renderer/mod.ts";
import { Dimension } from "./components/dimension.ts";
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
import { CollisionSystem } from "./systems/collision_system.ts";
import { NetworkSystem } from "./systems/network_system.ts";
import { Connection } from "./network.ts";
export interface ChatLine {
text: string;
time: number;
}
export class ClientWorld extends World {
paused = false;
debugging = false;
dimension!: Dimension;
connection: Connection;
chat_log: ChatLine[] = [];
constructor(connection: Connection) {
super("game");
this.connection = connection;
this.add_state("main_menu");
this.add_state("paused");
this.add_state("game");
self.addEventListener("resize", resize_canvas);
resize_canvas();
canvas.addEventListener("contextmenu", function (event) {
event.preventDefault();
});
start_game(this);
// Logic systems
this.add_system(new UIInteractionSystem(), "main_menu");
this.add_system(new UIInteractionSystem(), "paused");
this.add_system(new NetworkSystem(), "game");
this.add_system(new GuiTickSystem(), "game");
this.add_system(new PlayerControlsSystem(), "game");
this.add_system(new WorldGenerationSystem(), "game");
this.add_system(new CollisionSystem(), "game");
this.add_system(new MovementSystem(), "game");
// render systems
this.add_system(new RenderSystem(), "game");
this.add_system(new GuiRenderSystem(), "game");
this.add_system(new DebugSystem(), "game");
this.add_system(new UIRenderSystem(), "main_menu");
this.add_system(new UIRenderSystem(), "paused");
}
add_chat(text: string) {
this.chat_log.push({ text, time: performance.now() });
if (this.chat_log.length > 100) {
this.chat_log.shift();
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class Camera extends Component {
x = 0;
y = 0;
z = 3;
pitch = 0;
yaw = 0;
roll = 0;
fov = Math.PI / 3;
near = 0.1;
far = 1000;
}
-13
View File
@@ -1,13 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class ClickableSprite extends Component {
clicked = false;
button: number;
access_range: number;
constructor(button: number = 0, access_range = 2) {
super();
this.button = button;
this.access_range = access_range;
}
}
-20
View File
@@ -1,20 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class CollisionCuboid extends Component {
width: number;
height: number;
depth: number;
gravity: number;
colliding_x: number = 0;
colliding_y: number = 0;
colliding_z: number = 0;
constructor(width: number, height: number, depth: number, gravity = -15.8) {
super();
this.width = width;
this.height = height;
this.depth = depth;
this.gravity = gravity;
}
}
-29
View File
@@ -1,29 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
import { KeyCode } from "$/client/input_manager.ts";
export class PlayerControls extends Component {
move_speed = 4;
jump_force = 6.7;
// Keys
move_forward: KeyCode = "KeyW";
move_backwards: KeyCode = "KeyS";
move_left: KeyCode = "KeyA";
move_right: KeyCode = "KeyD";
sprint_key: KeyCode = "ShiftLeft";
hotbar_1: KeyCode = "Digit1";
hotbar_2: KeyCode = "Digit2";
hotbar_3: KeyCode = "Digit3";
hotbar_4: KeyCode = "Digit4";
hotbar_5: KeyCode = "Digit5";
hotbar_6: KeyCode = "Digit6";
hotbar_7: KeyCode = "Digit7";
hotbar_8: KeyCode = "Digit8";
hotbar_9: KeyCode = "Digit9";
open_inventory: KeyCode = "KeyE";
open_chat: KeyCode = "KeyT";
open_debug: KeyCode = "F3";
}
-87
View File
@@ -1,87 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
import { AssetManager } from "$/client/assets.ts";
import { Texture } from "../renderer/mod.ts";
export class Sprite extends Component {
image: Texture;
width: number;
height: number;
source_x: number;
source_y: number;
source_width: number;
source_height: number;
flip_x = false;
flip_y = false;
constructor(
image: Texture | string,
width: number,
height: number,
source_x = 0,
source_y = 0,
source_width = width,
source_height = height,
) {
super();
if (typeof image === "string") {
this.image = AssetManager.instance.get(image);
} else {
this.image = image;
}
this.width = width;
this.height = height;
this.source_x = source_x;
this.source_y = source_y;
this.source_width = source_width;
this.source_height = source_height;
}
}
interface AnimatedSpritePiece {
source_x: number[];
source_y: number[];
source_width: number;
source_height: number;
duration: number;
}
export class AnimatedSprite extends Component {
image: Texture;
width: number;
height: number;
flip_x = false;
flip_y = false;
current_state: string;
states: Record<string, AnimatedSpritePiece>;
timer = 0;
animation_frame = 0;
constructor(
image: Texture | string,
width: number,
height: number,
states: Record<string, AnimatedSpritePiece>,
initial_state: string,
) {
super();
if (typeof image === "string") {
this.image = AssetManager.instance.get(image);
} else {
this.image = image;
}
this.width = width;
this.height = height;
this.states = states;
this.current_state = initial_state;
}
set_state(state: string) {
if (this.current_state !== state) {
this.current_state = state;
this.timer = 0;
this.animation_frame = 0;
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import { Component } from "$/common/ecs/mod.ts";
export class UIButton extends Component {
text: string;
width: number;
height: number;
on_click: () => void;
hovered = false;
constructor(text: string, width: number, height: number, on_click: () => void) {
super();
this.text = text;
this.width = width;
this.height = height;
this.on_click = on_click;
}
}
+100
View File
@@ -0,0 +1,100 @@
import type { ClientLevel } from "../level/client_level.ts";
// anything that exists in the level and moves, like minecraft's Entity. position is the middle of its feet
export abstract class Entity {
id: string;
level: ClientLevel;
x = 0;
y = 0;
z = 0;
// blocks per second
vx = 0;
vy = 0;
vz = 0;
yaw = 0;
pitch = 0;
// the collision box, width is used for both x and z
width: number;
height: number;
eye_height: number;
gravity = -15.8;
// which way it hit something on each axis in the last move: 1 or -1, 0 for nothing.
// 1 on y means it's standing on something
colliding_x = 0;
colliding_y = 0;
colliding_z = 0;
constructor(level: ClientLevel, id: string, width: number, height: number, eye_height: number) {
this.level = level;
this.id = id;
this.width = width;
this.height = height;
this.eye_height = eye_height;
}
get on_ground() {
return this.colliding_y === 1;
}
set_position(x: number, y: number, z: number) {
this.x = x;
this.y = y;
this.z = z;
}
abstract tick(delta: number): void;
// falls and moves by its velocity, stopping on each axis it would run into a block on
move(delta: number) {
this.vy += this.gravity * delta;
this.colliding_x = this.vx !== 0 && this.#collides(this.x + this.vx * delta, this.y, this.z)
? -Math.sign(this.vx)
: 0;
if (this.colliding_x !== 0) {
this.vx = 0;
}
this.colliding_y = this.vy !== 0 && this.#collides(this.x, this.y + this.vy * delta, this.z)
? -Math.sign(this.vy)
: 0;
if (this.colliding_y !== 0) {
this.vy = 0;
}
this.colliding_z = this.vz !== 0 && this.#collides(this.x, this.y, this.z + this.vz * delta)
? -Math.sign(this.vz)
: 0;
if (this.colliding_z !== 0) {
this.vz = 0;
}
this.x += this.vx * delta;
this.y += this.vy * delta;
this.z += this.vz * delta;
}
// whether the collision box at x/y/z overlaps any block, unloaded chunks included
#collides(x: number, y: number, z: number) {
const min_x = Math.floor(x - this.width / 2);
const max_x = Math.floor(x + this.width / 2);
const min_y = Math.floor(y);
const max_y = Math.floor(y + this.height);
const min_z = Math.floor(z - this.width / 2);
const max_z = Math.floor(z + this.width / 2);
for (let bx = min_x; bx <= max_x; bx++) {
for (let by = min_y; by <= max_y; by++) {
for (let bz = min_z; bz <= max_z; bz++) {
if (this.level.get_block(bx, by, bz)) {
return true;
}
}
}
}
return false;
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { Client } from "../client.ts";
import { InputManager } from "../input_manager.ts";
import { ClientInventories } from "../inventory.ts";
import { Player } from "./player.ts";
// how often the position goes to the server
const SEND_POSITION_INTERVAL = 1 / 10;
// the player this client controls, like minecraft's LocalPlayer
export class LocalPlayer extends Player {
client: Client;
inventories = new ClientInventories();
move_speed = 4;
jump_force = 6.7;
#send_timer = 0;
constructor(client: Client, id: string, name: string) {
super(client.level, id, name);
this.client = client;
}
tick(delta: number) {
if (!this.client.screen) {
this.#apply_input();
}
this.move(delta);
this.#send_position(delta);
}
// turns with the mouse, like minecraft's MouseHandler.turnPlayer
turn(mouse_dx: number, mouse_dy: number) {
this.yaw += -mouse_dx * 0.001;
this.pitch += -mouse_dy * 0.001;
const limit = Math.PI / 2 - 0.01;
this.pitch = Math.max(-limit, Math.min(limit, this.pitch));
}
// walking relative to where it's looking
#apply_input() {
const options = this.client.options;
let input_x = 0;
let input_z = 0;
if (InputManager.is_key_down(options.key_left)) {
input_x -= 1;
}
if (InputManager.is_key_down(options.key_right)) {
input_x += 1;
}
if (InputManager.is_key_down(options.key_forward)) {
input_z -= 1;
}
if (InputManager.is_key_down(options.key_back)) {
input_z += 1;
}
const size = Math.hypot(input_x, input_z);
if (size > 0) {
input_x /= size;
input_z /= size;
}
const sin = Math.sin(this.yaw);
const cos = Math.cos(this.yaw);
const speed = this.move_speed * (InputManager.is_key_down(options.key_sprint) ? 1.75 : 1);
this.vx = (sin * input_z + cos * input_x) * speed;
this.vz = (cos * input_z - sin * input_x) * speed;
if (InputManager.is_key_down(options.key_jump) && this.on_ground) {
this.vy += this.jump_force;
}
}
#send_position(delta: number) {
this.#send_timer += delta;
if (this.#send_timer < SEND_POSITION_INTERVAL) {
return;
}
this.#send_timer = 0;
this.client.connection.send({
type: "move",
x: this.x,
y: this.y,
z: this.z,
yaw: this.yaw,
pitch: this.pitch,
});
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { ClientLevel } from "../level/client_level.ts";
import { Entity } from "./entity.ts";
export abstract class Player extends Entity {
name: string;
constructor(level: ClientLevel, id: string, name: string) {
super(level, id, 0.55, 1.79, 1.69);
this.name = name;
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { PlayerInfo } from "$/common/protocol.ts";
import type { ClientLevel } from "../level/client_level.ts";
import { Player } from "./player.ts";
const SMOOTHING = 12;
// another player on the server, it moves where the server says instead of simulating anything
export class RemotePlayer extends Player {
color: [number, number, number];
// where the server last said it is, the drawn position eases towards it so movement isn't choppy
target_x: number;
target_y: number;
target_z: number;
constructor(level: ClientLevel, info: PlayerInfo) {
super(level, info.id, info.name);
this.set_position(info.x, info.y, info.z);
this.target_x = info.x;
this.target_y = info.y;
this.target_z = info.z;
this.yaw = info.yaw;
this.pitch = info.pitch;
this.color = color_from_name(info.name);
}
lerp_to(x: number, y: number, z: number, yaw: number, pitch: number) {
this.target_x = x;
this.target_y = y;
this.target_z = z;
this.yaw = yaw;
this.pitch = pitch;
}
tick(delta: number) {
const t = Math.min(1, delta * SMOOTHING);
this.x += (this.target_x - this.x) * t;
this.y += (this.target_y - this.y) * t;
this.z += (this.target_z - this.z) * t;
}
}
function color_from_name(name: string): [number, number, number] {
let hash = 0;
for (const ch of name) {
hash = (hash * 31 + ch.charCodeAt(0)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
// hsl with s=0.6 l=0.6 to rgb
const c = 0.48;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = 0.36;
const [r, g, b] = hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m];
}
-35
View File
@@ -1,35 +0,0 @@
import { Entity } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "./client_world.ts";
import { Dimension } from "./components/dimension.ts";
import { create_player } from "./player.ts";
import { UIButton } from "./components/ui_components.ts";
import { open_about } from "./about.ts";
import { canvas } from "./renderer/mod.ts";
export function start_game(world: ClientWorld) {
world.state = "game";
world.clear_entities();
const dimension = new Entity("dimension");
world.dimension?.dispose();
world.dimension = new Dimension(world, world.connection.seed);
for (const [x, y, z, id, state] of world.connection.initial_changes) {
world.dimension.record_change(x, y, z, id, state);
}
dimension.add(world.dimension);
world.add_entity(dimension);
create_player(world);
// UI !
const unpause_button = new Entity("unpausebutton");
unpause_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
unpause_button.add(new UIButton("Unpause", 320, 64, () => world.state = "paused"));
world.add_entity(unpause_button);
const about_button = new Entity("aboutbutton");
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
about_button.add(new UIButton("About", 320, 64, () => open_about()));
world.add_entity(about_button);
}
+84
View File
@@ -0,0 +1,84 @@
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AIR, FACE_OFFSETS } from "$/common/constants.ts";
import type { Client } from "./client.ts";
import type { BlockHitResult } from "./level/client_level.ts";
// breaking and using blocks against a server, like minecraft's MultiPlayerGameMode. the client times breaking
// and guesses the results so they feel instant, the server decides what really happens
export class MultiPlayerGameMode {
client: Client;
// the block being broken, and how far along it is in seconds out of destroy_time
destroy_pos: { x: number; y: number; z: number } | undefined;
destroy_progress = 0;
destroy_time = 0;
constructor(client: Client) {
this.client = client;
}
// the click that starts breaking, for mods' on_click
start_destroy_block(hit: BlockHitResult) {
this.client.connection.send({ type: "hit_block", x: hit.x, y: hit.y, z: hit.z });
}
// called every frame the attack button is held on a block
continue_destroy_block(hit: BlockHitResult, delta: number) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", hit.block)!;
const tool_type = this.#held_item()?.tool_type;
this.destroy_pos = { x: hit.x, y: hit.y, z: hit.z };
this.destroy_time = block_info.toughness ?? 9999;
this.destroy_progress += delta * (tool_type === block_info.tool_to_break ? 2 : 1);
if (this.destroy_progress >= this.destroy_time) {
// show it right away, the server decides drops and corrects us if it disagrees
this.client.level.break_block(hit.x, hit.y, hit.z);
this.client.connection.send({ type: "break_block", x: hit.x, y: hit.y, z: hit.z });
this.destroy_progress = 0;
this.destroy_time = 0;
}
}
stop_destroy_block() {
this.destroy_pos = undefined;
this.destroy_progress = 0;
this.destroy_time = 0;
}
// right click on a block
use_item_on(hit: BlockHitResult) {
const level = this.client.level;
this.client.connection.send({ type: "use_block", x: hit.x, y: hit.y, z: hit.z, face: hit.face });
// guess that it places the held block, unless the block does something when used
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", hit.block)!;
const offset = FACE_OFFSETS[hit.face];
const target = { x: hit.x + offset.x, y: hit.y + offset.y, z: hit.z + offset.z };
const target_id = level.get_block(target.x, target.y, target.z);
const replaceable = target_id === AIR ||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
const held = this.#held_item();
// items with components might do something else on the server, like on_use
const place_id = held?.components ? undefined : held?.block_id;
if (!block_info.interactive && place_id && replaceable) {
level.add_block({ ...target, id: place_id });
const slot = this.#held_slot();
slot.amount = slot.amount! - 1;
}
}
// right click on nothing
use_item() {
this.client.connection.send({ type: "use_item" });
}
#held_slot() {
const inventories = this.client.player.inventories;
return inventories.inventory.get_slot(inventories.hotbar_selected);
}
#held_item() {
return EverythingRegistry.get<ItemRegistry>("items", this.#held_slot().type_id ?? "");
}
}
+38
View File
@@ -0,0 +1,38 @@
import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts";
const LINE_HEIGHT = 24;
const VISIBLE_SECONDS = 10;
const MAX_LINES = 10;
const MAX_HISTORY = 100;
interface ChatLine {
text: string;
time: number;
}
// the chat log, like minecraft's ChatComponent
export class ChatComponent {
lines: ChatLine[] = [];
add(text: string) {
this.lines.push({ text, time: performance.now() });
if (this.lines.length > MAX_HISTORY) {
this.lines.shift();
}
}
// above the bottom left corner. `all` shows old messages too, for when the chat is open
render(all: boolean, bottom = canvas.height - 100) {
const now = performance.now();
const lines = this.lines
.filter((line) => all || now - line.time < VISIBLE_SECONDS * 1000)
.slice(-MAX_LINES);
let y = bottom - lines.length * LINE_HEIGHT;
for (const line of lines) {
draw_rect(0, y, 600, LINE_HEIGHT, [0, 0, 0, 0.4]);
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
y += LINE_HEIGHT;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { Client } from "$/client/client.ts";
import { DebugUI } from "$/client/debug_ui.ts";
// f3: every entity's fields, editable
export class DebugOverlay {
render(client: Client) {
DebugUI.begin("Entities", 10, 10, 300);
for (const entity of client.level.entities.values()) {
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
this.#render_fields(entity);
}
}
if (DebugUI.collapsing_header("Camera")) {
this.#render_fields(client.camera);
}
if (DebugUI.collapsing_header("Options")) {
this.#render_fields(client.options);
}
DebugUI.end();
}
// deno-lint-ignore no-explicit-any
#render_fields(object: any) {
for (const key in object) {
const value = object[key];
if (typeof value === "number") {
object[key] = DebugUI.float_input(key, value);
} else if (typeof value === "string") {
object[key] = DebugUI.text_input(key, value);
} else if (typeof value === "boolean") {
object[key] = DebugUI.checkbox(key, value);
} else if (Array.isArray(value)) {
DebugUI.text(`${key}: ${JSON.stringify(value.slice(0, 10))}`);
} else if (value && typeof value === "object" && value.constructor !== Object) {
// other objects like the level point back at this one, only name them
DebugUI.text(`${key}: ${value.constructor.name}`);
} else {
DebugUI.text(`${key}: ${JSON.stringify(value)}`);
}
DebugUI.separator();
}
}
}
+7 -13
View File
@@ -1,29 +1,27 @@
import { GuiScreen } from "./gui_screen.ts";
import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts";
import { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts";
import { PlayerComponent } from "../player.ts";
import type { Client } from "../client.ts";
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
import { render_chat_log } from "../systems/rendering/network.ts";
export class GuiChat extends GuiScreen {
world: ClientWorld;
client: Client;
text_typed = "";
caret = 0;
key_repeat_timer = 0;
show_caret = true;
constructor(world: ClientWorld) {
constructor(client: Client) {
super();
this.world = world;
this.client = client;
}
override on_render(): void {
draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]);
const y = canvas.height - 32;
render_chat_log(this.world, true, y - 8);
this.client.chat.render(true, y - 8);
draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]);
draw_text(this.text_typed, 0, y, 2, [1, 1, 1]);
@@ -103,14 +101,10 @@ export class GuiChat extends GuiScreen {
submit() {
// commands like /give run on the server too
if (this.text_typed.trim().length > 0) {
this.world.connection.send({ type: "chat", text: this.text_typed });
this.client.connection.send({ type: "chat", text: this.text_typed });
}
this.text_typed = "";
const [player] = this.world.get_tag("player")!;
const player_component = player.get(PlayerComponent);
if (player_component) {
player_component.pop_screen();
}
this.client.pop_screen();
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/render
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { ClientMessage, ScreenLayout } from "$/common/protocol.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { draw_nine_slice } from "../rendering/render_utils.ts";
import { get_sprite_region } from "$/client/sprites.ts";
import { ClientInventories } from "../inventory.ts";
+1 -1
View File
@@ -2,7 +2,7 @@ import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } fro
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts";
import { SLOT_SIZE } from "../../common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { draw_nine_slice } from "../rendering/render_utils.ts";
import { ClientInventories } from "../inventory.ts";
import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
+1 -1
View File
@@ -6,7 +6,7 @@ import { AssetManager } from "../assets.ts";
import { InputManager } from "../input_manager.ts";
import { ClientInventories } from "../inventory.ts";
import { canvas, Texture } from "../renderer/mod.ts";
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts";
export class Slot {
container: ContainerKey;
-21
View File
@@ -1,21 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "../client_world.ts";
import { PlayerComponent } from "../player.ts";
export class GuiRenderSystem extends System {
update(world: ClientWorld, _delta: number): void {
const [player] = world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
player_component.screens.at(-1)?.on_render();
}
}
export class GuiTickSystem extends System {
update(world: ClientWorld, delta: number): void {
const [player] = world.get_tag("player")!;
const player_component = player.get(PlayerComponent)!;
player_component.screens.at(-1)?.on_tick(delta);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { SLOT_SIZE } from "$/common/constants.ts";
import { AssetManager } from "$/client/assets.ts";
import type { Client } from "$/client/client.ts";
import type { ClientInventories } from "$/client/inventory.ts";
import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.ts";
import { canvas, draw_rect_stroke, Texture } from "$/client/renderer/mod.ts";
const PADDING = 10;
const CROSSHAIR_SIZE = 8;
// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat
export class Hud {
render(client: Client) {
this.#render_hotbar(client.player.inventories);
this.#render_crosshair();
client.chat.render(false);
}
#render_hotbar(inventories: ClientInventories) {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
const hotbar_height = PADDING * 2 + SLOT_SIZE;
const x = canvas.width / 2 - hotbar_width / 2;
const y = canvas.height - hotbar_height;
draw_nine_slice(ui, 160, 0, 16, 16, 4, 4, 4, 4, x, y, hotbar_width, hotbar_height);
for (let index = 0; index < 9; index += 1) {
const selected = inventories.hotbar_selected === index;
draw_nine_slice(
ui,
selected ? 19 * 16 : 160 + 32,
selected ? 16 : 0,
16,
16,
4,
4,
4,
4,
x + PADDING + index * SLOT_SIZE,
y + PADDING,
SLOT_SIZE,
SLOT_SIZE,
);
}
for (let index = 0; index < 9; index += 1) {
const item = inventories.inventory.get_item(index);
if (item) {
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
}
}
}
#render_crosshair() {
draw_rect_stroke(
(canvas.width - CROSSHAIR_SIZE) / 2,
(canvas.height - CROSSHAIR_SIZE) / 2,
CROSSHAIR_SIZE,
CROSSHAIR_SIZE,
[0, 0, 0, 0.6],
);
}
}
@@ -1,4 +1,3 @@
import { Component } from "$/common/ecs/mod.ts";
import { block_value, chunk_key, default_block_value } from "$/common/utils.ts";
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import {
@@ -11,13 +10,13 @@ import {
} from "$/common/everything_registry.ts";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
import { AssetManager } from "../assets.ts";
import { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts";
import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts";
import { crosses_planes } from "../workers/translucent_sort.ts";
import { Camera } from "./camera.ts";
import { Camera } from "../camera.ts";
import type { Entity } from "../entity/entity.ts";
export interface Block {
id: string;
@@ -64,12 +63,21 @@ const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS;
export { chunk_key };
// the block being looked at and which face of it
export interface BlockHitResult {
x: number;
y: number;
z: number;
block: number;
face: Faces;
}
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
// light spreads diagonally too, so meshing and lighting need all 8
const ALL_NEIGHBOR_OFFSETS = [...NEIGHBOR_OFFSETS, [-1, -1], [1, -1], [-1, 1], [1, 1]] as const;
export class Dimension extends Component {
world: ClientWorld;
// what minecraft calls the ClientLevel: this client's copy of the world, its chunks and the entities in it
export class ClientLevel {
image: Texture = AssetManager.instance.get("bworld:textures");
chunks = new Map<number, Chunk>();
second_timer = 0;
@@ -84,9 +92,10 @@ export class Dimension extends Component {
// chunks being generated by a worker, by chunk key
pending_generation = new Map<number, { x: number; z: number }>();
constructor(world: ClientWorld, seed = "seed") {
super();
this.world = world;
// every entity this client knows about, the local player included, by id
entities = new Map<string, Entity>();
constructor(seed = "seed") {
this.seed = seed;
this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message));
@@ -106,6 +115,63 @@ export class Dimension extends Component {
});
}
add_entity(entity: Entity) {
this.entities.set(entity.id, entity);
}
remove_entity(id: string) {
this.entities.delete(id);
}
tick(delta: number) {
for (const entity of this.entities.values()) {
entity.tick(delta);
}
}
// generates the chunks around a position and forgets the ones too far away. one extra ring past the render
// distance gets generated so the edge has neighbors to mesh against
update_loaded_chunks(x: number, z: number, render_distance: number) {
const center_x = Math.floor(x / CHUNK_SIZE);
const center_z = Math.floor(z / CHUNK_SIZE);
const load_distance = render_distance + 1;
const out_of_range = (cx: number, cz: number) =>
Math.max(Math.abs(cx - center_x), Math.abs(cz - center_z)) > load_distance;
// collect first, deleting from the map while iterating it skips entries
const to_unload = [...this.chunks.values()].filter((chunk) => out_of_range(chunk.x, chunk.z));
for (const chunk of to_unload) {
this.unload_chunk(chunk.x, chunk.z);
}
for (const pending of [...this.pending_generation.values()]) {
if (out_of_range(pending.x, pending.z)) {
this.cancel_chunk_request(pending.x, pending.z);
}
}
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
const max_in_flight = this.workers.size * 2;
if (this.pending_generation.size >= max_in_flight) {
return;
}
const missing: { x: number; z: number; distance: number }[] = [];
for (let cx = center_x - load_distance; cx <= center_x + load_distance; cx += 1) {
for (let cz = center_z - load_distance; cz <= center_z + load_distance; cz += 1) {
if (!this.is_generated(cx, cz) && !this.is_generating(cx, cz)) {
const dx = cx - center_x;
const dz = cz - center_z;
missing.push({ x: cx, z: cz, distance: dx * dx + dz * dz });
}
}
}
missing.sort((a, b) => a.distance - b.distance);
for (const chunk of missing.slice(0, max_in_flight - this.pending_generation.size)) {
this.request_chunk(chunk.x, chunk.z);
}
}
dispose() {
this.workers.terminate();
for (const chunk of this.chunks.values()) {
@@ -532,24 +598,21 @@ export class Dimension extends Component {
chunk.translucent_sort = undefined;
}
get_looked_block(
dimension: Dimension,
camera: Camera,
// the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick
pick(
x: number,
y: number,
z: number,
yaw: number,
pitch: number,
max_distance = 6,
step = 0.05,
): { x: number; y: number; z: number; block: number; face: Faces } | undefined {
const yaw = camera.yaw;
const pitch = camera.pitch;
): BlockHitResult | undefined {
const cos_pitch = Math.cos(pitch);
const dx = -Math.sin(yaw) * cos_pitch;
const dy = Math.sin(pitch);
const dz = -Math.cos(yaw) * cos_pitch;
let x = camera.x;
let y = camera.y;
let z = camera.z;
let prev_bx = Math.floor(x);
let prev_by = Math.floor(y);
let prev_bz = Math.floor(z);
@@ -570,7 +633,7 @@ export class Dimension extends Component {
continue;
}
const block = dimension.get_block(bx, by, bz);
const block = this.get_block(bx, by, bz);
if (block && block !== AIR && block !== VOID) {
let face: Faces;
+10 -10
View File
@@ -1,5 +1,5 @@
import { AssetManager } from "./assets.ts";
import { ClientWorld } from "./client_world.ts";
import { Client } from "./client.ts";
import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
@@ -7,18 +7,18 @@ import { confirm_mods } from "./confirm_mods.ts";
import { ModLoadError } from "$/common/mod_loader.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_world } from "./mods.ts";
import { load_client_mods, set_mods_client } from "./mods.ts";
export class ClientLoop {
running = false;
last_time = 0;
world: ClientWorld;
client: Client;
frame_count = 0;
last_fps_time = 0;
constructor(world: ClientWorld) {
this.world = world;
constructor(client: Client) {
this.client = client;
}
start() {
@@ -49,7 +49,7 @@ export class ClientLoop {
begin_drawing();
clear_background(0.69, 0.8, 1, 1.0);
this.world.update(delta);
this.client.run_frame(delta);
end_drawing();
this.frame_count += 1;
@@ -121,11 +121,11 @@ async function join_server(): Promise<Connection> {
try {
const connection = await join_server();
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
const client = new Client(connection);
set_mods_client(client);
client.chat.add("Connected to the server");
const loop = new ClientLoop(client_world);
const loop = new ClientLoop(client);
loop.start();
console.log("Game started");
-19
View File
@@ -1,19 +0,0 @@
import { Entity } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "./client_world.ts";
import { UIButton } from "./components/ui_components.ts";
import { open_about } from "./about.ts";
import { start_game } from "./game.ts";
import { canvas } from "./renderer/mod.ts";
export function create_main_menu(world: ClientWorld) {
const play_button = new Entity("play");
play_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80));
play_button.add(new UIButton("Play", 320, 64, () => start_game(world)));
world.add_entity(play_button);
const about_button = new Entity("aboutbutton");
about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80));
about_button.add(new UIButton("About", 320, 64, () => open_about()));
world.add_entity(about_button);
}
+12 -14
View File
@@ -6,18 +6,17 @@ import type { ClientContext } from "$/common/mod_api/client.ts";
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts";
import type { ClientWorld } from "./client_world.ts";
import type { Client } from "./client.ts";
import { code_url, fetch_verified } from "./handshake.ts";
// what the chunk workers need to generate the same world as the server
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
// set once the world exists, mods can't look at it during setup
let world: ClientWorld | undefined;
// set once the game is running, mods can't look at it during setup
let client: Client | undefined;
export function set_mods_world(client_world: ClientWorld) {
world = client_world;
export function set_mods_client(game_client: Client) {
client = game_client;
}
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
@@ -65,9 +64,9 @@ function client_context(listing: ModListing): ClientContext {
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
},
});
const need_world = () => {
if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`);
return world;
const need_client = () => {
if (!client) throw new Error(`[${mod}] the world isn't there yet during setup`);
return client;
};
return {
@@ -78,17 +77,16 @@ function client_context(listing: ModListing): ClientContext {
net: not_yet("net", "step 8") as ClientContext["net"],
player: {
get name() {
return need_world().connection.name;
return need_client().connection.name;
},
get position() {
const [player] = need_world().get_tag("player")!;
const position = player.get(Position)!;
return { x: position.x, y: position.y, z: position.z };
const { x, y, z } = need_client().player;
return { x, y, z };
},
},
world: {
get_block(x, y, z) {
const nid = need_world().dimension.get_block(x, y, z);
const nid = need_client().level.get_block(x, y, z);
if (nid === AIR) return AIR_ID;
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
},
+4 -47
View File
@@ -2,14 +2,6 @@ import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/
import type { ModListing } from "$/common/mod_loader.ts";
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
export interface RemotePlayer extends PlayerInfo {
// where we draw them, eased towards x/y/z so movement isnt choppy
display_x: number;
display_y: number;
display_z: number;
color: [number, number, number];
}
export class Connection {
#server: ServerSocket;
id: string;
@@ -19,7 +11,8 @@ export class Connection {
initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
players = new Map<string, RemotePlayer>();
// who was already there when we joined, they become entities in the level
initial_players: PlayerInfo[];
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
this.#server = server;
@@ -30,12 +23,10 @@ export class Connection {
this.initial_changes = join.changes;
this.spawn = join.spawn;
this.selected_slot = join.selected_slot;
for (const player of join.players) {
this.add_player(player);
}
this.initial_players = join.players;
}
// handled by the network system 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
get incoming(): ServerMessage[] {
return this.#server.messages;
}
@@ -47,40 +38,6 @@ export class Connection {
send(message: ClientMessage) {
this.#server.send(message);
}
add_player(player: PlayerInfo) {
this.players.set(player.id, {
...player,
display_x: player.x,
display_y: player.y,
display_z: player.z,
color: color_from_name(player.name),
});
}
}
function color_from_name(name: string): [number, number, number] {
let hash = 0;
for (const ch of name) {
hash = (hash * 31 + ch.charCodeAt(0)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
// hsl with s=0.6 l=0.6 to rgb
const c = 0.48;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = 0.36;
const [r, g, b] = hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m];
}
export function get_player_name(): string {
+28
View File
@@ -0,0 +1,28 @@
import type { KeyCode } from "./input_manager.ts";
// the player's settings, like minecraft's Options and its key mappings
export class Options {
render_distance = 6;
key_forward: KeyCode = "KeyW";
key_back: KeyCode = "KeyS";
key_left: KeyCode = "KeyA";
key_right: KeyCode = "KeyD";
key_jump: KeyCode = "Space";
key_sprint: KeyCode = "ShiftLeft";
key_inventory: KeyCode = "KeyE";
key_chat: KeyCode = "KeyT";
key_debug: KeyCode = "F3";
key_fullscreen: KeyCode = "F11";
key_hotbar: KeyCode[] = [
"Digit1",
"Digit2",
"Digit3",
"Digit4",
"Digit5",
"Digit6",
"Digit7",
"Digit8",
"Digit9",
];
}
+88
View File
@@ -0,0 +1,88 @@
import type { ServerMessage } from "$/common/protocol.ts";
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";
// 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
export class ClientPacketListener {
client: Client;
constructor(client: Client) {
this.client = client;
}
handle_packets() {
const connection = this.client.connection;
for (const message of connection.incoming) {
this.#handle(message);
}
connection.incoming.length = 0;
}
#handle(message: ServerMessage) {
const client = this.client;
const level = client.level;
const inventories = client.player.inventories;
switch (message.type) {
case "player_join":
level.add_entity(new RemotePlayer(level, message.player));
break;
case "player_leave":
level.remove_entity(message.id);
break;
case "player_move": {
const player = level.entities.get(message.id);
if (player instanceof RemotePlayer) {
player.lerp_to(message.x, message.y, message.z, message.yaw, message.pitch);
}
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);
break;
case "chat":
client.chat.add(message.from ? `<${message.from}> ${message.text}` : message.text);
break;
case "container": {
let container = message.container === "screen" ? inventories.screen : inventories[message.container];
if (message.container === "screen" && container?.size !== message.items.length) {
container = inventories.screen = new Container(message.items.length);
}
container?.load(message.items);
break;
}
case "cursor":
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
break;
case "open_screen": {
// replace anything open locally without telling the server, it just opened this one
client.screens.length = 0;
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
inventories.screen = new Container(size);
client.push_screen(
new GuiContainer(inventories, (m) => client.connection.send(m), message.layout, message.properties),
);
break;
}
case "screen_properties": {
const screen = client.screen;
if (screen instanceof GuiContainer) {
screen.properties = message.properties;
}
break;
}
case "teleport":
client.player.set_position(message.x, message.y, message.z);
break;
case "close_screen":
// closed by the server (the block broke), it already put everything back
client.screens = client.screens.filter((s) => !(s instanceof GuiContainer));
inventories.screen = undefined;
break;
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import { Position } from "$/common/components/position.ts";
import { Velocity } from "$/common/components/velocity.ts";
import { Component, Entity } from "$/common/ecs/mod.ts";
import { Camera } from "$/client/components/camera.ts";
import { ClientInventories } from "./inventory.ts";
import { PlayerControls } from "$/client/components/player_controls.ts";
import { ClientWorld } from "./client_world.ts";
import { GuiScreen } from "./gui/gui_screen.ts";
import { CollisionCuboid } from "./components/collision.ts";
export class PlayerComponent extends Component {
inventories = new ClientInventories();
screens: GuiScreen[] = [];
render_distance = 6;
breaking_block?: { x: number; y: number; z: number };
break_progress = 0;
break_progress_max = 0;
pop_screen() {
const screen = this.screens.pop();
if (screen) {
screen.on_close();
}
}
}
export function create_player(world: ClientWorld) {
const player = new Entity("player");
const spawn = world.connection.spawn;
player.add(new Position(spawn.x, spawn.y, spawn.z));
player.add(new Velocity(0, 0, 0));
player.add(new PlayerControls());
const player_component = player.add(new PlayerComponent());
player_component.inventories.hotbar_selected = world.connection.selected_slot;
const camera = player.add(new Camera());
camera.yaw = spawn.yaw;
camera.pitch = spawn.pitch;
player.add(new CollisionCuboid(0.55, 1.79, 0.55));
world.add_entity(player);
const player_hand = new Entity("playerhand");
player_hand.add(new Position(0, 0));
world.add_entity(player_hand);
world.add_tag("player", [player, player_hand]);
return player;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { Camera } from "../components/camera.ts";
import type { Camera } from "../camera.ts";
import { mat4 } from "gl-matrix";
import type { RenderLayer } from "$/common/everything_registry.ts";
import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts";
+30
View File
@@ -0,0 +1,30 @@
import type { Client } from "$/client/client.ts";
import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts";
import { Hud } from "$/client/gui/hud.ts";
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
import { LevelRenderer } from "./level_renderer.ts";
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
export class GameRenderer {
level_renderer = new LevelRenderer();
hud = new Hud();
debug_overlay = new DebugOverlay();
render(client: Client) {
const camera = client.camera;
camera.setup(client.player);
begin_mode_3d(camera);
this.level_renderer.render_opaque(client.level, camera);
this.level_renderer.render_destroy_progress(client.game_mode);
this.level_renderer.render_entities(client.level, client.player);
this.level_renderer.render_translucent(client.level, camera);
end_mode_3d();
this.hud.render(client);
client.screen?.on_render();
if (client.debugging) {
this.debug_overlay.render(client);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import { TEXTURE_SIZE } from "$/common/constants.ts";
import { CHUNK_SIZE, ClientLevel } from "$/client/level/client_level.ts";
import { Camera } from "$/client/camera.ts";
import { AssetManager } from "$/client/assets.ts";
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 {
draw_terrain,
flush_batch,
push_back_face,
push_bottom_face,
push_box,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
set_current_texture,
Texture,
white_tex,
} from "$/client/renderer/mod.ts";
const BREAKING_FACES = [
push_back_face,
push_bottom_face,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
];
// draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities
export class LevelRenderer {
// solid and cutout terrain, drawn before entities
render_opaque(level: ClientLevel, camera: Camera) {
level.request_meshes(camera);
level.update_translucent_sorting(camera);
set_current_texture(level.image.tex);
for (const chunk of level.chunks.values()) {
const mesh = chunk.meshes.solid;
if (mesh) {
draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count);
}
}
for (const chunk of level.chunks.values()) {
const mesh = chunk.meshes.cutout;
if (mesh) {
draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count);
}
}
}
// translucent terrain, drawn after entities so they show through water and glass.
// chunks go back to front, and each chunk's quads are already sorted back to front
render_translucent(level: ClientLevel, camera: Camera) {
const distance_sq = (x: number, z: number) => {
const dx = (x + 0.5) * CHUNK_SIZE - camera.x;
const dz = (z + 0.5) * CHUNK_SIZE - camera.z;
return dx * dx + dz * dz;
};
const chunks = [...level.chunks.values()]
.filter((chunk) => chunk.meshes.translucent)
.map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) }))
.sort((a, b) => b.distance - a.distance);
set_current_texture(level.image.tex);
for (const { mesh } of chunks) {
draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer);
}
}
// every entity but the one the camera is in
render_entities(level: ClientLevel, camera_entity: Entity) {
flush_batch();
set_current_texture(white_tex!);
for (const entity of level.entities.values()) {
if (entity !== camera_entity && entity instanceof RemotePlayer) {
render_player(entity);
}
}
flush_batch();
}
// the cracks on the block being broken
render_destroy_progress(game_mode: MultiPlayerGameMode) {
const block = game_mode.destroy_pos;
if (!block) {
return;
}
const progress = Math.max(0, Math.min(1, game_mode.destroy_progress / game_mode.destroy_time));
const stage = Math.round(progress * 8);
if (Number.isNaN(stage)) {
return;
}
const tex = AssetManager.instance.get<Texture>("bworld:textures");
const region = get_sprite_region(`engine:break_${stage}`);
for (const push_face of BREAKING_FACES) {
push_face(
tex,
block.x,
block.y,
block.z,
region.x * TEXTURE_SIZE,
region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
);
}
}
}
// a box body and head in the player's color
function render_player(player: RemotePlayer) {
const [r, g, b] = player.color;
const { x, y, z } = player;
// body
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
// head
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
}
-101
View File
@@ -1,101 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "../client_world.ts";
import { CollisionCuboid } from "$/client/components/collision.ts";
import { Velocity } from "$/common/components/velocity.ts";
import { Dimension } from "../components/dimension.ts";
export class CollisionSystem extends System {
override update(world: ClientWorld, delta: number): void {
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const velocity = entity.get(Velocity);
const cuboid = entity.get(CollisionCuboid);
if (!position || !velocity || !cuboid) {
continue;
}
velocity.vy += cuboid.gravity * delta;
let new_position = position.clone();
new_position.x += velocity.vx * delta;
let collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
cuboid.colliding_x = collisions.x;
if (collisions.x !== 0) {
velocity.vx = 0;
}
new_position = position.clone();
new_position.y += velocity.vy * delta;
collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
cuboid.colliding_y = collisions.y;
if (collisions.y !== 0) {
velocity.vy = 0;
}
new_position = position.clone();
new_position.z += velocity.vz * delta;
collisions = this.check_collision(new_position, velocity, cuboid, world.dimension);
cuboid.colliding_z = collisions.z;
if (collisions.z !== 0) {
velocity.vz = 0;
}
}
}
check_collision(
position: Position,
velocity: Velocity,
cuboid: CollisionCuboid,
dimension: Dimension,
): { x: number; y: number; z: number } {
const collisions = { x: 0, y: 0, z: 0 };
const min_x = Math.floor(position.x - cuboid.width / 2);
const max_x = Math.floor(position.x + cuboid.width / 2);
const min_y = Math.floor(position.y);
const max_y = Math.floor(position.y + cuboid.height);
const min_z = Math.floor(position.z - cuboid.depth / 2);
const max_z = Math.floor(position.z + cuboid.depth / 2);
for (let x = min_x; x <= max_x; x++) {
for (let y = min_y; y <= max_y; y++) {
for (let z = min_z; z <= max_z; z++) {
const block = dimension.get_block(x, y, z);
if (block && block !== 0) {
if (velocity.vx > 0) {
collisions.x = -1;
}
if (velocity.vx < 0) {
collisions.x = 1;
}
if (velocity.vy > 0) {
collisions.y = -1;
}
if (velocity.vy < 0) {
collisions.y = 1;
}
if (velocity.vz > 0) {
collisions.z = -1;
}
if (velocity.vz < 0) {
collisions.z = 1;
}
}
}
}
}
return collisions;
}
}
-59
View File
@@ -1,59 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "$/client/client_world.ts";
import { DebugUI } from "$/client/debug_ui.ts";
export class DebugSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, _delta: number): void {
if (!world.debugging) {
return;
}
DebugUI.begin("Entities", 10, 10, 300);
for (const entity of world.get_entities()) {
if (DebugUI.collapsing_header("Entity - " + entity.id)) {
for (const component of entity.get_all()) {
if (DebugUI.collapsing_header(`${component.constructor.name}##${entity.id}`)) {
this.render_component(component);
}
}
}
}
DebugUI.end();
}
// deno-lint-ignore no-explicit-any
render_component(component: any) {
for (const key in component) {
if (key === "__component") {
continue;
}
if (typeof component[key] === "number") {
component[key] = DebugUI.float_input(
key,
component[key],
);
} else if (typeof component[key] === "string") {
component[key] = DebugUI.text_input(
key,
component[key],
);
} else if (typeof component[key] === "boolean") {
component[key] = DebugUI.checkbox(
key,
component[key],
);
} else if (Array.isArray(component[key])) {
DebugUI.text(`${key}: ${JSON.stringify(component[key].slice(0, 10))}`);
} else {
DebugUI.text(`${key}: ${JSON.stringify(component[key])}`);
}
DebugUI.separator();
}
}
}
-122
View File
@@ -1,122 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "../client_world.ts";
import { Camera } from "../components/camera.ts";
import { Container, ItemStack } from "$/common/inventory.ts";
import { PlayerComponent } from "../player.ts";
import { GuiContainer } from "../gui/gui_container.ts";
import { show_fatal_error } from "../fatal.ts";
const MOVE_SEND_INTERVAL = 1 / 10;
const REMOTE_PLAYER_SMOOTHING = 12;
export class NetworkSystem extends System {
move_timer = 0;
update(world: ClientWorld, delta: number): void {
const connection = world.connection;
const [local_player] = world.get_tag("player")!;
const player_component = local_player.get(PlayerComponent)!;
const inventories = player_component.inventories;
for (const message of connection.incoming) {
switch (message.type) {
case "player_join":
connection.add_player(message.player);
break;
case "player_leave":
connection.players.delete(message.id);
break;
case "player_move": {
const player = connection.players.get(message.id);
if (player) {
player.x = message.x;
player.y = message.y;
player.z = message.z;
player.yaw = message.yaw;
player.pitch = message.pitch;
}
break;
}
case "set_block":
world.dimension.record_change(message.x, message.y, message.z, message.id, message.state);
world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state);
break;
case "chat":
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
break;
case "container": {
let container = message.container === "screen"
? inventories.screen
: inventories[message.container];
if (message.container === "screen" && container?.size !== message.items.length) {
container = inventories.screen = new Container(message.items.length);
}
container?.load(message.items);
break;
}
case "cursor":
inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined;
break;
case "open_screen": {
// replace anything open locally without telling the server, it just opened this one
player_component.screens.length = 0;
const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1));
inventories.screen = new Container(size);
player_component.screens.push(
new GuiContainer(inventories, (m) => connection.send(m), message.layout, message.properties),
);
break;
}
case "screen_properties": {
const screen = player_component.screens.at(-1);
if (screen instanceof GuiContainer) {
screen.properties = message.properties;
}
break;
}
case "teleport": {
const position = local_player.get(Position)!;
position.x = message.x;
position.y = message.y;
position.z = message.z;
break;
}
case "close_screen":
// closed by the server (the block broke), it already put everything back
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
inventories.screen = undefined;
break;
}
}
connection.incoming.length = 0;
if (connection.closed) {
show_fatal_error("Lost connection to the server");
return;
}
const t = Math.min(1, delta * REMOTE_PLAYER_SMOOTHING);
for (const player of connection.players.values()) {
player.display_x += (player.x - player.display_x) * t;
player.display_y += (player.y - player.display_y) * t;
player.display_z += (player.z - player.display_z) * t;
}
this.move_timer += delta;
if (this.move_timer >= MOVE_SEND_INTERVAL) {
this.move_timer = 0;
const [player] = world.get_tag("player")!;
const position = player.get(Position)!;
const camera = player.get(Camera)!;
connection.send({
type: "move",
x: position.x,
y: position.y,
z: position.z,
yaw: camera.yaw,
pitch: camera.pitch,
});
}
}
}
-205
View File
@@ -1,205 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Velocity } from "$/common/components/velocity.ts";
import { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts";
import { PlayerControls } from "../components/player_controls.ts";
import { Camera } from "../components/camera.ts";
import { Position } from "../../common/components/position.ts";
import { PlayerComponent } from "../player.ts";
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
import { CollisionCuboid } from "../components/collision.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AIR, FACE_OFFSETS } from "$/common/constants.ts";
import { ClientMessage } from "$/common/protocol.ts";
import { GuiChat } from "../gui/gui_chat.ts";
import { GuiInventoryScreen } from "../gui/gui_screen.ts";
export class PlayerControlsSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, delta: number): void {
const [player] = world.get_tag("player")!;
const velocity = player.get(Velocity)!;
const controls = player.get(PlayerControls)!;
const player_component = player.get(PlayerComponent)!;
const position = player.get(Position)!;
const camera = player.get(Camera)!;
const send = (message: ClientMessage) => world.connection.send(message);
if (player_component.screens.length === 0) {
let input_x = 0;
let input_z = 0;
if (InputManager.is_key_down(controls.move_left)) {
input_x -= 1;
}
if (InputManager.is_key_down(controls.move_right)) {
input_x += 1;
}
if (InputManager.is_key_down(controls.move_forward)) {
input_z -= 1;
}
if (InputManager.is_key_down(controls.move_backwards)) {
input_z += 1;
}
const size = Math.hypot(input_x, input_z);
if (size > 0) {
input_x /= size;
input_z /= size;
}
const sin = Math.sin(camera.yaw);
const cos = Math.cos(camera.yaw);
const forwardX = sin;
const forwardZ = cos;
const rightX = cos;
const rightZ = -sin;
const speed_modifier = InputManager.is_key_down(controls.sprint_key) ? 1.75 : 1;
velocity.vx = (forwardX * input_z + rightX * input_x) * controls.move_speed * speed_modifier;
velocity.vz = (forwardZ * input_z + rightZ * input_x) * controls.move_speed * speed_modifier;
const cuboid = player.get(CollisionCuboid);
if (InputManager.is_key_down("Space") && cuboid?.colliding_y === 1) {
velocity.vy += controls.jump_force;
}
}
if (InputManager.is_key_pressed(controls.open_inventory)) {
if (player_component.screens.length === 0) {
player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send));
} else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
player_component.pop_screen();
}
}
if (InputManager.is_key_pressed(controls.open_chat)) {
if (player_component.screens.length === 0) {
player_component.screens.push(new GuiChat(world));
}
}
if (InputManager.is_key_pressed("Escape")) {
player_component.pop_screen();
}
if (InputManager.is_key_pressed(controls.open_debug)) {
world.debugging = !world.debugging;
}
if (InputManager.is_key_pressed("F11")) {
InputManager.toggle_fullscreen();
}
camera.x = position.x;
camera.y = position.y + 1.69;
camera.z = position.z;
if (InputManager.is_mouse_grabbed()) {
const mouse_delta = InputManager.get_mouse_delta();
camera.yaw += -mouse_delta.x * 0.001;
camera.pitch += -mouse_delta.y * 0.001;
const limit = Math.PI / 2 - 0.01;
camera.pitch = Math.max(-limit, Math.min(limit, camera.pitch));
}
InputManager.set_mouse_grabbed(player_component.screens.length === 0);
if (InputManager.is_mouse_down(0) && player_component.screens.length === 0) {
player_component.breaking_block = { x: 0, y: 9999, z: 0 };
} else {
player_component.breaking_block = undefined;
player_component.break_progress_max = 0;
player_component.break_progress = 0;
}
const block = world.dimension.get_looked_block(world.dimension, camera);
const inventories = player_component.inventories;
const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected);
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", hotbar_slot.type_id ?? "");
if (block && player_component.screens.length === 0) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
if (InputManager.is_mouse_pressed(0)) {
// for mods' on_click, breaking itself is timed here and sent when done
send({ type: "hit_block", x: block.x, y: block.y, z: block.z });
}
if (player_component.breaking_block) {
player_component.breaking_block = { x: block.x, y: block.y, z: block.z };
player_component.break_progress_max = block_info.toughness ?? 9999;
let multiplier = 1;
if (holding_item_info?.tool_type === block_info.tool_to_break) {
multiplier *= 2;
}
player_component.break_progress += delta * multiplier;
if (player_component.break_progress >= player_component.break_progress_max) {
// show it right away, the server decides drops and corrects us if it disagrees
world.dimension.break_block(block.x, block.y, block.z);
send({ type: "break_block", x: block.x, y: block.y, z: block.z });
player_component.break_progress_max = 0;
player_component.break_progress = 0;
}
} else if (InputManager.is_mouse_pressed(2)) {
send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
// guess that it places the held block, unless the block does something when used
const offset = FACE_OFFSETS[block.face];
const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
const target_id = world.dimension.get_block(target.x, target.y, target.z);
const replaceable = target_id === AIR ||
EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.replaceable;
// items with components might do something else on the server, like on_use
const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id;
if (!block_info.interactive && place_id && replaceable) {
world.dimension.add_block({ ...target, id: place_id });
hotbar_slot.amount = hotbar_slot.amount! - 1;
}
}
} else {
player_component.breaking_block = undefined;
player_component.break_progress_max = 0;
player_component.break_progress = 0;
if (!block && player_component.screens.length === 0 && InputManager.is_mouse_pressed(2)) {
send({ type: "use_item" });
}
}
if (player_component.screens.length === 0) {
const previous = inventories.hotbar_selected;
const scroll = InputManager.get_wheel_delta();
if (scroll > 0) {
inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1);
} else if (scroll < 0) {
inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1);
}
const hotbar_keys = [
controls.hotbar_1,
controls.hotbar_2,
controls.hotbar_3,
controls.hotbar_4,
controls.hotbar_5,
controls.hotbar_6,
controls.hotbar_7,
controls.hotbar_8,
controls.hotbar_9,
];
const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
if (pressed !== -1) {
inventories.hotbar_selected = pressed;
}
if (inventories.hotbar_selected !== previous) {
send({ type: "select_slot", slot: inventories.hotbar_selected });
}
}
}
}
-78
View File
@@ -1,78 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
import { Dimension } from "../components/dimension.ts";
import { Camera } from "$/client/components/camera.ts";
import { render_animated_sprite, render_sprite } from "./rendering/sprites.ts";
import { render_dimension_opaque, render_dimension_translucent } from "./rendering/dimension.ts";
import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts";
import { PlayerComponent } from "../player.ts";
import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts";
import { ClientWorld } from "../client_world.ts";
import { render_chat_log, render_remote_players } from "./rendering/network.ts";
export class RenderSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, _delta: number): void {
const camera_entity = world.get_entities().values().find((e) => e.get(Camera));
const camera = camera_entity?.get(Camera);
if (!camera) {
return;
}
begin_mode_3d(camera);
for (const entity of world.get_entities()) {
const dimension = entity.get(Dimension);
if (dimension) {
render_dimension_opaque(dimension, camera);
}
const player_component = entity.get(PlayerComponent);
if (player_component) {
render_player_breaking(player_component);
}
}
if (world.connection) {
render_remote_players(world.connection);
}
for (const entity of world.get_entities()) {
const dimension = entity.get(Dimension);
if (dimension) {
render_dimension_translucent(dimension, camera);
}
}
end_mode_3d();
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const sprite = entity.get(Sprite);
if (position && sprite) {
render_sprite(sprite, position);
}
const animated_sprite = entity.get(AnimatedSprite);
if (position && animated_sprite) {
render_animated_sprite(animated_sprite, position);
}
const player_component = entity.get(PlayerComponent);
if (player_component) {
render_player_hotbar(player_component.inventories);
render_player_crosshair();
}
}
render_chat_log(world, false);
}
}
-45
View File
@@ -1,45 +0,0 @@
import { CHUNK_SIZE, Dimension } from "$/client/components/dimension.ts";
import { Camera } from "$/client/components/camera.ts";
import { draw_terrain, set_current_texture } from "$/client/renderer/mod.ts";
// solid and cutout terrain, drawn before entities
export function render_dimension_opaque(dimension: Dimension, camera: Camera) {
dimension.request_meshes(camera);
dimension.update_translucent_sorting(camera);
set_current_texture(dimension.image.tex);
for (const chunk of dimension.chunks.values()) {
const mesh = chunk.meshes.solid;
if (mesh) {
draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count);
}
}
for (const chunk of dimension.chunks.values()) {
const mesh = chunk.meshes.cutout;
if (mesh) {
draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count);
}
}
}
// translucent terrain, drawn after entities so they show through water and glass.
// chunks go back to front, and each chunk's quads are already sorted back to front
export function render_dimension_translucent(dimension: Dimension, camera: Camera) {
const distance_sq = (x: number, z: number) => {
const dx = (x + 0.5) * CHUNK_SIZE - camera.x;
const dz = (z + 0.5) * CHUNK_SIZE - camera.z;
return dx * dx + dz * dz;
};
const chunks = [...dimension.chunks.values()]
.filter((chunk) => chunk.meshes.translucent)
.map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) }))
.sort((a, b) => b.distance - a.distance);
set_current_texture(dimension.image.tex);
for (const { mesh } of chunks) {
draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer);
}
}
-53
View File
@@ -1,53 +0,0 @@
import { ClientWorld } from "$/client/client_world.ts";
import { Connection } from "$/client/network.ts";
import {
canvas,
draw_rect,
draw_text,
flush_batch,
push_box,
set_current_texture,
white_tex,
} from "$/client/renderer/mod.ts";
const CHAT_LINE_HEIGHT = 24;
const CHAT_VISIBLE_SECONDS = 10;
const CHAT_MAX_LINES = 10;
export function render_remote_players(connection: Connection) {
if (connection.players.size === 0) {
return;
}
flush_batch();
set_current_texture(white_tex!);
for (const player of connection.players.values()) {
const [r, g, b] = player.color;
const x = player.display_x;
const y = player.display_y;
const z = player.display_z;
// body
push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);
// head
push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65);
}
flush_batch();
}
// draws the chat log above the bottom left corner, `all` shows old messages too (when the chat is open)
export function render_chat_log(world: ClientWorld, all: boolean, bottom = canvas.height - 100) {
const now = performance.now();
const lines = world.chat_log
.filter((line) => all || now - line.time < CHAT_VISIBLE_SECONDS * 1000)
.slice(-CHAT_MAX_LINES);
let y = bottom - lines.length * CHAT_LINE_HEIGHT;
for (const line of lines) {
draw_rect(0, y, 600, CHAT_LINE_HEIGHT, [0, 0, 0, 0.4]);
draw_text(line.text, 4, y, 2, [1, 1, 1, 1]);
y += CHAT_LINE_HEIGHT;
}
}
-120
View File
@@ -1,120 +0,0 @@
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { AssetManager } from "$/client/assets.ts";
import { ClientInventories } from "../../inventory.ts";
import { draw_item, draw_nine_slice } from "./render_utils.ts";
import {
canvas,
draw_rect_stroke,
push_back_face,
push_bottom_face,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
Texture,
} from "$/client/renderer/mod.ts";
import { PlayerComponent } from "../../player.ts";
import { get_sprite_region } from "$/client/sprites.ts";
const PADDING = 10;
export function render_player_hotbar(inventories: ClientInventories) {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9;
const hotbar_height = PADDING * 2 + SLOT_SIZE;
const x = canvas.width / 2 - hotbar_width / 2;
const y = canvas.height - hotbar_height;
draw_nine_slice(
ui,
160,
0,
16,
16,
4,
4,
4,
4,
x,
y,
hotbar_width,
hotbar_height,
);
for (let index = 0; index < 9; index += 1) {
draw_nine_slice(
ui,
inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
inventories.hotbar_selected === index ? 16 : 0,
16,
16,
4,
4,
4,
4,
x + PADDING + index * SLOT_SIZE,
y + PADDING,
SLOT_SIZE,
SLOT_SIZE,
);
}
for (let index = 0; index < 9; index += 1) {
const item = inventories.inventory.get_item(index);
if (item) {
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
}
}
}
export function render_player_crosshair() {
const CROSSHAIR_SIZE = 8;
draw_rect_stroke(
(canvas.width - CROSSHAIR_SIZE) / 2,
(canvas.height - CROSSHAIR_SIZE) / 2,
CROSSHAIR_SIZE,
CROSSHAIR_SIZE,
[0, 0, 0, 0.6],
);
}
const FACE_FUNCTIONS = [
push_back_face,
push_bottom_face,
push_front_face,
push_left_face,
push_right_face,
push_top_face,
];
export function render_player_breaking(player_component: PlayerComponent) {
const block = player_component.breaking_block;
if (block) {
const tex = AssetManager.instance.get<Texture>("bworld:textures");
const progress = Math.max(
0,
Math.min(1, player_component.break_progress / player_component.break_progress_max),
);
const break_sprite = Math.round(progress * 8);
if (Number.isNaN(break_sprite)) {
return;
}
const region = get_sprite_region(`engine:break_${break_sprite}`);
for (const fn of FACE_FUNCTIONS) {
fn(
tex,
block.x,
block.y,
block.z,
region.x * TEXTURE_SIZE,
region.y * TEXTURE_SIZE,
TEXTURE_SIZE,
TEXTURE_SIZE,
);
}
}
}
-54
View File
@@ -1,54 +0,0 @@
import { Position } from "$/common/components/position.ts";
import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts";
import { draw_texture_region } from "$/client/renderer/mod.ts";
export function render_sprite(sprite: Sprite, position: Position) {
draw_texture_region(
sprite.image,
sprite.source_x,
sprite.source_y,
sprite.source_width,
sprite.source_height,
position.x,
position.y,
sprite.width,
sprite.height,
sprite.flip_x,
sprite.flip_y,
);
}
export function render_animated_sprite(
animated_sprite: AnimatedSprite,
position: Position,
) {
const current_animation = animated_sprite.states[animated_sprite.current_state];
if (!current_animation) {
console.error(`Missing animation for state ${animated_sprite.current_state}`);
return;
}
draw_texture_region(
animated_sprite.image,
current_animation.source_x[animated_sprite.animation_frame],
current_animation.source_y[animated_sprite.animation_frame],
current_animation.source_width,
current_animation.source_height,
position.x,
position.y,
animated_sprite.width,
animated_sprite.height,
animated_sprite.flip_x,
animated_sprite.flip_y,
);
animated_sprite.timer += 1;
if (animated_sprite.timer >= current_animation.duration) {
animated_sprite.timer = 0;
animated_sprite.animation_frame += 1;
if (animated_sprite.animation_frame >= current_animation.source_x.length) {
animated_sprite.animation_frame = 0;
}
}
}
-26
View File
@@ -1,26 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { point_inside_rec } from "$/common/utils.ts";
import { ClientWorld } from "$/client/client_world.ts";
import { UIButton } from "$/client/components/ui_components.ts";
import { InputManager } from "$/client/input_manager.ts";
export class UIInteractionSystem implements System {
update(world: ClientWorld, _delta: number) {
const mouse = InputManager.get_mouse_position();
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const button = entity.get(UIButton);
if (position && button) {
const hovered = point_inside_rec(mouse.x, mouse.y, position.x, position.y, button.width, button.height);
button.hovered = hovered;
if (hovered && InputManager.is_mouse_pressed(0)) {
InputManager.consume_mouse(0);
button.on_click();
}
}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "$/client/client_world.ts";
import { UIButton } from "$/client/components/ui_components.ts";
import { draw_nine_slice } from "./rendering/render_utils.ts";
import { AssetManager } from "../assets.ts";
import { draw_text, Texture } from "../renderer/mod.ts";
const SCALE = 4;
export class UIRenderSystem implements System {
constructor() {}
update(world: ClientWorld, _delta: number) {
const ui = AssetManager.instance.get<Texture>("bworld:ui");
for (const entity of world.get_entities()) {
const position = entity.get(Position);
const button = entity.get(UIButton);
if (position && button) {
draw_nine_slice(
ui,
16 * (button.hovered ? 14 : 11),
16 * (button.hovered ? 1 : 0),
16,
16,
3,
3,
3,
3,
position.x / SCALE,
position.y / SCALE,
button.width / SCALE,
button.height / SCALE,
);
// const measure = measure_text(ctx, button.text, 1.25);
draw_text(
button.text,
(position.x / SCALE) + (button.width / SCALE / 2) - (100 / 2),
position.y / SCALE + (button.height / SCALE / 2),
//1.5,
//"white",
//"middle",
);
}
}
}
}
-64
View File
@@ -1,64 +0,0 @@
import { System } from "$/common/ecs/mod.ts";
import { ClientWorld } from "../client_world.ts";
import { Position } from "../../common/components/position.ts";
import { CHUNK_SIZE } from "../components/dimension.ts";
import { PlayerComponent } from "$/client/player.ts";
export class WorldGenerationSystem extends System {
constructor() {
super();
}
update(world: ClientWorld, _delta: number): void {
const [player] = world.get_tag("player")!;
const position = player.get(Position)!;
const player_component = player.get(PlayerComponent)!;
const dimension = world.dimension;
const player_chunk_x = Math.floor(position.x / CHUNK_SIZE);
const player_chunk_z = Math.floor(position.z / CHUNK_SIZE);
// one extra ring gets generated so the edge of the render distance has neighbors to mesh against
const load_distance = player_component.render_distance + 1;
const out_of_range = (x: number, z: number) =>
Math.max(Math.abs(x - player_chunk_x), Math.abs(z - player_chunk_z)) > load_distance;
// collect first, deleting from the map while iterating it skips entries
const to_unload = [];
for (const chunk of dimension.chunks.values()) {
if (out_of_range(chunk.x, chunk.z)) {
to_unload.push(chunk);
}
}
for (const chunk of to_unload) {
dimension.unload_chunk(chunk.x, chunk.z);
}
for (const pending of [...dimension.pending_generation.values()]) {
if (out_of_range(pending.x, pending.z)) {
dimension.cancel_chunk_request(pending.x, pending.z);
}
}
// dont queue up the whole area at once, so walking somewhere new gets the close chunks first
const max_in_flight = dimension.workers.size * 2;
if (dimension.pending_generation.size >= max_in_flight) {
return;
}
const missing: { x: number; z: number; distance: number }[] = [];
for (let x = player_chunk_x - load_distance; x <= player_chunk_x + load_distance; x += 1) {
for (let z = player_chunk_z - load_distance; z <= player_chunk_z + load_distance; z += 1) {
if (!dimension.is_generated(x, z) && !dimension.is_generating(x, z)) {
const dx = x - player_chunk_x;
const dz = z - player_chunk_z;
missing.push({ x, z, distance: dx * dx + dz * dz });
}
}
}
missing.sort((a, b) => a.distance - b.distance);
for (const chunk of missing.slice(0, max_in_flight - dimension.pending_generation.size)) {
dimension.request_chunk(chunk.x, chunk.z);
}
}
}