diff --git a/client/camera.ts b/client/camera.ts index aa5dc6c..928a4bc 100644 --- a/client/camera.ts +++ b/client/camera.ts @@ -14,10 +14,11 @@ export class Camera { near = 0.1; far = 1000; - setup(entity: Entity) { - this.x = entity.x; - this.y = entity.y + entity.eye_height; - this.z = entity.z; + setup(entity: Entity, partial_tick: number) { + const { x, y, z } = entity.render_position(partial_tick); + this.x = x; + this.y = y + entity.eye_height; + this.z = z; this.yaw = entity.yaw; this.pitch = entity.pitch; } diff --git a/client/client.ts b/client/client.ts index f58912c..b14da88 100644 --- a/client/client.ts +++ b/client/client.ts @@ -15,9 +15,13 @@ 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"; +import { TICK_DELTA } from "$/common/constants.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 +// after a long stall (a hidden tab, a debugger) the game skips ahead instead of running every missed tick at once +const MAX_TICKS_PER_FRAME = 10; + +// the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer. +// the game runs in fixed ticks (TICKS_PER_SECOND), input and drawing happen every frame export class Client { connection: Connection; options = new Options(); @@ -35,6 +39,12 @@ export class Client { hit_result: BlockHitResult | undefined; debugging = false; + // seconds of game time not ticked yet + #pending_time = 0; + // whether the attack button is held on a block, breaking it advances every tick + #attacking = false; + #stopped = false; + constructor(connection: Connection) { this.connection = connection; @@ -74,24 +84,46 @@ export class Client { this.screens.pop()?.on_close(); } - // one frame: network, input, the level and its entities, then drawing + // one frame: input, as many ticks as are due, then drawing between the last tick and the next run_frame(delta: number) { + this.screen?.on_tick(delta); + this.#handle_keybinds(); + + this.#pending_time += delta; + let ticks = 0; + while (this.#pending_time >= TICK_DELTA && ticks < MAX_TICKS_PER_FRAME) { + this.tick(); + if (this.#stopped) { + return; + } + this.#pending_time -= TICK_DELTA; + ticks += 1; + } + if (ticks === MAX_TICKS_PER_FRAME) { + this.#pending_time = Math.min(this.#pending_time, TICK_DELTA); + } + + this.game_renderer.render(this, this.#pending_time / TICK_DELTA); + } + + // one step of the game: what the server sent, breaking, chunk loading, then every entity + tick() { this.packet_listener.handle_packets(); if (this.connection.closed) { show_fatal_error("Lost connection to the server"); + this.#stopped = true; return; } - this.screen?.on_tick(delta); - this.#handle_keybinds(delta); + if (this.#attacking && this.hit_result) { + this.game_mode.continue_destroy_block(this.hit_result); + } this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance); - this.level.tick(delta); - - this.game_renderer.render(this); + this.level.tick(); } - #handle_keybinds(delta: number) { + #handle_keybinds() { const options = this.options; const player = this.player; @@ -122,16 +154,18 @@ export class Client { 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); + this.#handle_block_interaction(); if (!this.screen) { this.#handle_hotbar(); } } - #handle_block_interaction(delta: number) { + // clicks happen right away, holding to break advances in tick() + #handle_block_interaction() { const hit = this.hit_result; const attacking = !this.screen && InputManager.is_mouse_down(0); + this.#attacking = attacking; if (!attacking || !hit) { this.game_mode.stop_destroy_block(); @@ -144,9 +178,7 @@ export class Client { 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)) { + if (!attacking && InputManager.is_mouse_pressed(2)) { this.game_mode.use_item_on(hit); } } else if (InputManager.is_mouse_pressed(2)) { diff --git a/client/entity/entity.ts b/client/entity/entity.ts index 3fe0b11..0a81853 100644 --- a/client/entity/entity.ts +++ b/client/entity/entity.ts @@ -1,6 +1,13 @@ +import { TICK_DELTA } from "$/common/constants.ts"; 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 +// how far inside a block face still counts as touching it, so boxes resting exactly on a face don't snag +const EPSILON = 1e-7; +// the two axes that aren't the one being moved along +const OTHER_AXES = [[1, 2], [0, 2], [0, 1]] as const; + +// anything that exists in the level and moves, like minecraft's Entity. position is the middle of its feet. +// it's simulated in fixed ticks (common/constants.ts), frames draw it between its last two positions export abstract class Entity { id: string; level: ClientLevel; @@ -8,6 +15,10 @@ export abstract class Entity { x = 0; y = 0; z = 0; + // where it was at the start of the tick, what frames interpolate from + prev_x = 0; + prev_y = 0; + prev_z = 0; // blocks per second vx = 0; vy = 0; @@ -39,62 +50,92 @@ export abstract class Entity { return this.colliding_y === 1; } + // jumps there, without interpolating from where it was set_position(x: number, y: number, z: number) { - this.x = x; - this.y = y; - this.z = z; + this.x = this.prev_x = x; + this.y = this.prev_y = y; + this.z = this.prev_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; + save_previous_position() { + this.prev_x = this.x; + this.prev_y = this.y; + this.prev_z = this.z; } - // 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); + // where to draw it, partial_tick is how far the frame is between the last tick and the next + render_position(partial_tick: number) { + return { + x: this.prev_x + (this.x - this.prev_x) * partial_tick, + y: this.prev_y + (this.y - this.prev_y) * partial_tick, + z: this.prev_z + (this.z - this.prev_z) * partial_tick, + }; + } - 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; + // one step of the game, TICK_DELTA seconds + abstract tick(): void; + + // falls and moves by its velocity for one tick. like minecraft it moves along y, then x, then z, each time only + // as far as it can before touching a block, and stops its velocity on the axes it hit something + move() { + // the average of this tick's start and end speed, so the arc is the same at any tick rate + const wanted_y = (this.vy + this.gravity * TICK_DELTA / 2) * TICK_DELTA; + this.vy += this.gravity * TICK_DELTA; + const wanted = [this.vx * TICK_DELTA, wanted_y, this.vz * TICK_DELTA]; + + const half = this.width / 2; + const min = [this.x - half, this.y, this.z - half]; + const max = [this.x + half, this.y + this.height, this.z + half]; + const moved = [0, 0, 0]; + for (const axis of [1, 0, 2]) { + const distance = this.#clip(min, max, axis, wanted[axis]); + min[axis] += distance; + max[axis] += distance; + moved[axis] = distance; + } + + const hit = (axis: number) => moved[axis] !== wanted[axis] ? -Math.sign(wanted[axis]) : 0; + this.colliding_x = hit(0); + this.colliding_y = hit(1); + this.colliding_z = hit(2); + if (this.colliding_x !== 0) this.vx = 0; + if (this.colliding_y !== 0) this.vy = 0; + if (this.colliding_z !== 0) this.vz = 0; + + this.x += moved[0]; + this.y += moved[1]; + this.z += moved[2]; + } + + // how far the box can go along an axis before it runs into a block, unloaded chunks included. + // blocks it's already inside don't stop it, so it can get out of them + #clip(min: number[], max: number[], axis: number, distance: number) { + if (distance === 0) { + return 0; + } + + const [a, b] = OTHER_AXES[axis]; + const from = Math.floor(Math.min(min[axis], min[axis] + distance)); + const to = Math.floor(Math.max(max[axis], max[axis] + distance)); + const position = [0, 0, 0]; + + for (let i = from; i <= to; i++) { + for (let j = Math.floor(min[a] + EPSILON); j <= Math.floor(max[a] - EPSILON); j++) { + for (let k = Math.floor(min[b] + EPSILON); k <= Math.floor(max[b] - EPSILON); k++) { + position[axis] = i; + position[a] = j; + position[b] = k; + if (!this.level.get_block(position[0], position[1], position[2])) { + continue; + } + if (distance > 0 && i >= max[axis] - EPSILON) { + distance = Math.min(distance, i - max[axis]); + } else if (distance < 0 && i + 1 <= min[axis] + EPSILON) { + distance = Math.max(distance, i + 1 - min[axis]); } } } } - return false; + return distance; } } diff --git a/client/entity/local_player.ts b/client/entity/local_player.ts index 1dba573..e0d21ef 100644 --- a/client/entity/local_player.ts +++ b/client/entity/local_player.ts @@ -3,8 +3,8 @@ 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 position goes to the server every other tick +const SEND_POSITION_TICKS = 2; // the player this client controls, like minecraft's LocalPlayer export class LocalPlayer extends Player { @@ -14,22 +14,22 @@ export class LocalPlayer extends Player { move_speed = 4; jump_force = 6.7; - #send_timer = 0; + #ticks_since_sent = 0; constructor(client: Client, id: string, name: string) { super(client.level, id, name); this.client = client; } - tick(delta: number) { + tick() { if (!this.client.screen) { this.#apply_input(); } - this.move(delta); - this.#send_position(delta); + this.move(); + this.#send_position(); } - // turns with the mouse, like minecraft's MouseHandler.turnPlayer + // turns with the mouse every frame, not every tick, like minecraft's MouseHandler.turnPlayer turn(mouse_dx: number, mouse_dy: number) { this.yaw += -mouse_dx * 0.001; this.pitch += -mouse_dy * 0.001; @@ -75,12 +75,12 @@ export class LocalPlayer extends Player { } } - #send_position(delta: number) { - this.#send_timer += delta; - if (this.#send_timer < SEND_POSITION_INTERVAL) { + #send_position() { + this.#ticks_since_sent += 1; + if (this.#ticks_since_sent < SEND_POSITION_TICKS) { return; } - this.#send_timer = 0; + this.#ticks_since_sent = 0; this.client.connection.send({ type: "move", x: this.x, diff --git a/client/entity/remote_player.ts b/client/entity/remote_player.ts index ca48537..fdb8518 100644 --- a/client/entity/remote_player.ts +++ b/client/entity/remote_player.ts @@ -1,4 +1,5 @@ import type { PlayerInfo } from "$/common/protocol.ts"; +import { TICK_DELTA } from "$/common/constants.ts"; import type { ClientLevel } from "../level/client_level.ts"; import { Player } from "./player.ts"; @@ -32,8 +33,8 @@ export class RemotePlayer extends Player { this.pitch = pitch; } - tick(delta: number) { - const t = Math.min(1, delta * SMOOTHING); + tick() { + const t = Math.min(1, TICK_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; diff --git a/client/game_mode.ts b/client/game_mode.ts index 1c1960c..232d7bf 100644 --- a/client/game_mode.ts +++ b/client/game_mode.ts @@ -1,5 +1,5 @@ import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; -import { AIR, FACE_OFFSETS } from "$/common/constants.ts"; +import { AIR, FACE_OFFSETS, TICK_DELTA } from "$/common/constants.ts"; import type { Client } from "./client.ts"; import type { BlockHitResult } from "./level/client_level.ts"; @@ -22,14 +22,14 @@ export class MultiPlayerGameMode { 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) { + // called every tick the attack button is held on a block + continue_destroy_block(hit: BlockHitResult) { const block_info = EverythingRegistry.get_by_id("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); + this.destroy_progress += TICK_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 diff --git a/client/level/client_level.ts b/client/level/client_level.ts index 96be668..098b5d9 100644 --- a/client/level/client_level.ts +++ b/client/level/client_level.ts @@ -123,9 +123,10 @@ export class ClientLevel { this.entities.delete(id); } - tick(delta: number) { + tick() { for (const entity of this.entities.values()) { - entity.tick(delta); + entity.save_previous_position(); + entity.tick(); } } diff --git a/client/rendering/game_renderer.ts b/client/rendering/game_renderer.ts index fc5a12f..65e0f65 100644 --- a/client/rendering/game_renderer.ts +++ b/client/rendering/game_renderer.ts @@ -10,14 +10,15 @@ export class GameRenderer { hud = new Hud(); debug_overlay = new DebugOverlay(); - render(client: Client) { + // partial_tick is how far this frame is between the last tick and the next, entities are drawn in between + render(client: Client, partial_tick: number) { const camera = client.camera; - camera.setup(client.player); + camera.setup(client.player, partial_tick); 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_entities(client.level, client.player, partial_tick); this.level_renderer.render_translucent(client.level, camera); end_mode_3d(); diff --git a/client/rendering/level_renderer.ts b/client/rendering/level_renderer.ts index 45a44aa..0f3531a 100644 --- a/client/rendering/level_renderer.ts +++ b/client/rendering/level_renderer.ts @@ -75,13 +75,13 @@ export class LevelRenderer { } // every entity but the one the camera is in - render_entities(level: ClientLevel, camera_entity: Entity) { + render_entities(level: ClientLevel, camera_entity: Entity, partial_tick: number) { flush_batch(); set_current_texture(white_tex!); for (const entity of level.entities.values()) { if (entity !== camera_entity && entity instanceof RemotePlayer) { - render_player(entity); + render_player(entity, partial_tick); } } @@ -119,9 +119,9 @@ export class LevelRenderer { } // a box body and head in the player's color -function render_player(player: RemotePlayer) { +function render_player(player: RemotePlayer, partial_tick: number) { const [r, g, b] = player.color; - const { x, y, z } = player; + const { x, y, z } = player.render_position(partial_tick); // body push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b);