No more ecs
This commit is contained in:
@@ -1113,8 +1113,8 @@ loading mods (the base game and the template), their scripts and worldgen, saves
|
||||
|
||||
**Phase 4: engine cleanup.**
|
||||
|
||||
- Replace the hardcoded water checks in `client/systems/player_controls.ts` and `server/game/game_server.ts` with the
|
||||
`replaceable` block field.
|
||||
- Replace the hardcoded water checks in `server/game/game_server.ts` with the `replaceable` block field, like
|
||||
`client/game_mode.ts` already does.
|
||||
- `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have
|
||||
the same name.
|
||||
- Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means
|
||||
@@ -1179,5 +1179,6 @@ Moving the base game into `mods/bworld` happens alongside steps 4–9. See
|
||||
components to an existing block (for example to `bworld:grass`) is a possible middle ground.
|
||||
- **Screen scaling.** The GUI currently works in raw canvas pixels (`SLOT_SIZE` is 54). `Graphics` should probably use a
|
||||
UI scale the player can change, with screens laid out in scaled units.
|
||||
- **Entities.** The game's only entity is the player, so mob mods are out of scope until the ECS has more entity types.
|
||||
- **Entities.** The only entities are players (`client/entity/`), so mob mods are out of scope until there are more
|
||||
entity types.
|
||||
- **Hot reload.** Restarting the server and reconnecting is the version 1 answer.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Component } from "$/common/ecs/component.ts";
|
||||
|
||||
export class Position extends Component {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
|
||||
constructor(x: number, y: number, z = 0) {
|
||||
super();
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Position(this.x, this.y, this.z);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Component } from "$/common/ecs/component.ts";
|
||||
|
||||
export class Velocity extends Component {
|
||||
vx: number;
|
||||
vy: number;
|
||||
vz: number;
|
||||
|
||||
constructor(vx: number, vy: number, vz = 0) {
|
||||
super();
|
||||
this.vx = vx;
|
||||
this.vy = vy;
|
||||
this.vz = vz;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export abstract class Component {
|
||||
__component = true;
|
||||
}
|
||||
|
||||
export abstract class SerializableComponent extends Component {
|
||||
abstract serialize(): unknown;
|
||||
// right, this is static you cant do this,,
|
||||
// abstract deserialize<T>(data: unknown): T;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { Component } from "./component.ts";
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type ComponentConstructor<T extends Component> = new (...args: any[]) => T;
|
||||
|
||||
export class Entity {
|
||||
id: string;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
components = new Map<ComponentConstructor<any>, Component>();
|
||||
active = true;
|
||||
|
||||
constructor(id: string = crypto.randomUUID()) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
add<T extends Component>(component: T): T {
|
||||
this.components.set(component.constructor as ComponentConstructor<T>, component);
|
||||
return component;
|
||||
}
|
||||
|
||||
get<T extends Component>(type: ComponentConstructor<T>): T | undefined {
|
||||
return this.components.get(type) as T;
|
||||
}
|
||||
|
||||
get_all(): Iterable<Component> {
|
||||
return this.components.values();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./component.ts";
|
||||
export * from "./entity.ts";
|
||||
export * from "./system.ts";
|
||||
export * from "./world.ts";
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
export abstract class System {
|
||||
abstract update(world: World, delta: number): void;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { Entity } from "./entity.ts";
|
||||
import type { System } from "./system.ts";
|
||||
|
||||
export class World {
|
||||
#entities = new Set<Entity>();
|
||||
#entities_for_deletion = new Set<Entity>();
|
||||
|
||||
#systems = new Map<string, Set<System>>();
|
||||
#tags = new Map<string, Entity[]>();
|
||||
#states = new Set<string>();
|
||||
|
||||
#state: string = "";
|
||||
#new_state: string | undefined;
|
||||
|
||||
constructor(initial_state: string) {
|
||||
this.#state = initial_state;
|
||||
this.add_state("*");
|
||||
}
|
||||
|
||||
add_state(new_state: string) {
|
||||
this.#states.add(new_state);
|
||||
this.#systems.set(new_state, new Set());
|
||||
}
|
||||
|
||||
add_entity(entity: Entity) {
|
||||
this.#entities.add(entity);
|
||||
}
|
||||
|
||||
add_system(system: System, state: string) {
|
||||
console.assert(this.#states.has(state));
|
||||
this.#systems.get(state)!.add(system);
|
||||
}
|
||||
|
||||
add_tag(tag: string, entities: Entity[]) {
|
||||
this.#tags.set(tag, entities);
|
||||
}
|
||||
|
||||
get_tag(tag: string) {
|
||||
return this.#tags.get(tag);
|
||||
}
|
||||
|
||||
update(delta: number) {
|
||||
for (const system of this.#systems.get("*") ?? []) {
|
||||
system.update(this, delta);
|
||||
}
|
||||
for (const system of this.#systems.get(this.#state) ?? []) {
|
||||
system.update(this, delta);
|
||||
}
|
||||
// should be faster than Set.prototype.difference lol
|
||||
for (const entity of this.#entities_for_deletion) {
|
||||
this.#entities.delete(entity);
|
||||
}
|
||||
if (this.#new_state) {
|
||||
this.#state = this.#new_state;
|
||||
this.#new_state = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get_entities() {
|
||||
return this.#entities;
|
||||
}
|
||||
|
||||
delete_entity(entity: Entity) {
|
||||
this.#entities_for_deletion.add(entity);
|
||||
}
|
||||
|
||||
clear_entities() {
|
||||
for (const entity of this.#entities) {
|
||||
this.#entities_for_deletion.add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
set state(new_state: string) {
|
||||
this.#new_state = new_state;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { System, World } from "$/common/ecs/mod.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import { Velocity } from "$/common/components/velocity.ts";
|
||||
|
||||
export class MovementSystem extends System {
|
||||
update(world: World, delta: number): void {
|
||||
for (const entity of world.get_entities()) {
|
||||
const position = entity.get(Position);
|
||||
const velocity = entity.get(Velocity);
|
||||
|
||||
if (position && velocity) {
|
||||
position.x += velocity.vx * delta;
|
||||
position.y += velocity.vy * delta;
|
||||
position.z += velocity.vz * delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user