Day night cycle
This commit is contained in:
@@ -497,6 +497,8 @@ interface ServerWorld {
|
|||||||
drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops
|
drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops
|
||||||
is_loaded(x: number, z: number): boolean;
|
is_loaded(x: number, z: number): boolean;
|
||||||
readonly seed: string;
|
readonly seed: string;
|
||||||
|
readonly time: number; // world time, see below
|
||||||
|
set_time(time: number): void; // synced to every player
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Player {
|
interface Player {
|
||||||
@@ -533,6 +535,12 @@ interface ModStorage {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**World time** counts ticks since 06:00 of day 1 and never wraps around. A day (06:00 to 18:00) is 36000 ticks, 30
|
||||||
|
minutes, and a night (18:00 to 06:00) is 12000 ticks, 10 minutes, so in game hours are shorter at night. Dusk is the
|
||||||
|
first minute of the night and dawn the last, so it's bright all day. `common/time.ts` has `clock`, `format_clock`,
|
||||||
|
`is_day` and `daylight` for turning it into something readable. Client scripts can read it as `ctx.world.time`, and
|
||||||
|
players can use `/time`, `/time set <ticks|day|noon|sunset|night|midnight>` and `/time add <ticks>`.
|
||||||
|
|
||||||
The server loads chunks around every player (its simulation distance) and generates them itself with the same generator
|
The server loads chunks around every player (its simulation distance) and generates them itself with the same generator
|
||||||
as the clients. Blocks in unloaded chunks can't be read or changed, and don't tick.
|
as the clients. Blocks in unloaded chunks can't be read or changed, and don't tick.
|
||||||
|
|
||||||
@@ -549,7 +557,7 @@ ctx.commands.register("heal", {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using one of the
|
Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using one of the
|
||||||
engine's (`give` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
|
engine's (`give`, `time` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
|
||||||
|
|
||||||
## Client scripts
|
## Client scripts
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class Client {
|
|||||||
this.#on_disconnect = on_disconnect;
|
this.#on_disconnect = on_disconnect;
|
||||||
|
|
||||||
this.level = new ClientLevel(connection.seed);
|
this.level = new ClientLevel(connection.seed);
|
||||||
|
this.level.time = connection.time;
|
||||||
for (const [x, y, z, id, state] of connection.initial_changes) {
|
for (const [x, y, z, id, state] of connection.initial_changes) {
|
||||||
this.level.record_change(x, y, z, id, state);
|
this.level.record_change(x, y, z, id, state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Client } from "$/client/client.ts";
|
import type { Client } from "$/client/client.ts";
|
||||||
import { DebugUI } from "$/client/debug_ui.ts";
|
import { DebugUI } from "$/client/debug_ui.ts";
|
||||||
|
import { format_clock } from "$/common/time.ts";
|
||||||
|
|
||||||
// f3: every entity's fields, editable
|
// f3: every entity's fields, editable
|
||||||
export class DebugOverlay {
|
export class DebugOverlay {
|
||||||
render(client: Client) {
|
render(client: Client) {
|
||||||
DebugUI.begin("Entities", 10, 10, 300);
|
DebugUI.begin("Entities", 10, 10, 300);
|
||||||
|
DebugUI.text(`${format_clock(client.level.time)} (tick ${client.level.time})`);
|
||||||
|
DebugUI.separator();
|
||||||
|
|
||||||
for (const entity of client.level.entities.values()) {
|
for (const entity of client.level.entities.values()) {
|
||||||
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
|
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export class ClientLevel {
|
|||||||
second_timer = 0;
|
second_timer = 0;
|
||||||
tick_timer = 0;
|
tick_timer = 0;
|
||||||
seed: string;
|
seed: string;
|
||||||
|
// world time in ticks, counted here between the server's updates. see common/time.ts
|
||||||
|
time = 0;
|
||||||
|
|
||||||
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
|
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
|
||||||
changes = new Map<string, Map<string, BlockChange>>();
|
changes = new Map<string, Map<string, BlockChange>>();
|
||||||
@@ -129,6 +131,7 @@ export class ClientLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
|
this.time += 1;
|
||||||
for (const entity of this.entities.values()) {
|
for (const entity of this.entities.values()) {
|
||||||
entity.save_previous_position();
|
entity.save_previous_position();
|
||||||
entity.tick();
|
entity.tick();
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ function client_context(listing: ModListing): ClientContext {
|
|||||||
if (nid === AIR) return AIR_ID;
|
if (nid === AIR) return AIR_ID;
|
||||||
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
|
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
|
||||||
},
|
},
|
||||||
|
get time() {
|
||||||
|
return need_client().level.time;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
log: (...args) => console.log(`[${mod}]`, ...args),
|
log: (...args) => console.log(`[${mod}]`, ...args),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class Connection {
|
|||||||
initial_changes: BlockChange[];
|
initial_changes: BlockChange[];
|
||||||
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||||
selected_slot: number;
|
selected_slot: number;
|
||||||
|
time: number;
|
||||||
// who was already there when we joined, they become entities in the level
|
// who was already there when we joined, they become entities in the level
|
||||||
initial_players: PlayerInfo[];
|
initial_players: PlayerInfo[];
|
||||||
initial_entities: EntityInfo[];
|
initial_entities: EntityInfo[];
|
||||||
@@ -24,6 +25,7 @@ export class Connection {
|
|||||||
this.initial_changes = join.changes;
|
this.initial_changes = join.changes;
|
||||||
this.spawn = join.spawn;
|
this.spawn = join.spawn;
|
||||||
this.selected_slot = join.selected_slot;
|
this.selected_slot = join.selected_slot;
|
||||||
|
this.time = join.time;
|
||||||
this.initial_players = join.players;
|
this.initial_players = join.players;
|
||||||
this.initial_entities = join.entities;
|
this.initial_entities = join.entities;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export class ClientPacketListener {
|
|||||||
case "player_join":
|
case "player_join":
|
||||||
level.add_entity(new RemotePlayer(level, message.player));
|
level.add_entity(new RemotePlayer(level, message.player));
|
||||||
break;
|
break;
|
||||||
|
case "time":
|
||||||
|
level.time = message.time;
|
||||||
|
break;
|
||||||
case "player_leave":
|
case "player_leave":
|
||||||
level.remove_entity(message.id);
|
level.remove_entity(message.id);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,19 +1,41 @@
|
|||||||
import type { Client } from "$/client/client.ts";
|
import type { Client } from "$/client/client.ts";
|
||||||
import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts";
|
import { begin_mode_3d, clear_background, end_mode_3d, update_lightmap } from "$/client/renderer/mod.ts";
|
||||||
|
import { daylight } from "$/common/time.ts";
|
||||||
import { Hud } from "$/client/gui/hud.ts";
|
import { Hud } from "$/client/gui/hud.ts";
|
||||||
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
|
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
|
||||||
import { LevelRenderer } from "./level_renderer.ts";
|
import { LevelRenderer } from "./level_renderer.ts";
|
||||||
|
|
||||||
|
// the sky at noon and in the middle of the night
|
||||||
|
const DAY_SKY = [0.69, 0.8, 1];
|
||||||
|
const NIGHT_SKY = [0.02, 0.03, 0.08];
|
||||||
|
// minecraft's darkest sky: moonlight still lights things a little
|
||||||
|
const NIGHT_SKY_LIGHT = 0.2;
|
||||||
|
|
||||||
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
|
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
|
||||||
export class GameRenderer {
|
export class GameRenderer {
|
||||||
level_renderer = new LevelRenderer();
|
level_renderer = new LevelRenderer();
|
||||||
hud = new Hud();
|
hud = new Hud();
|
||||||
debug_overlay = new DebugOverlay();
|
debug_overlay = new DebugOverlay();
|
||||||
|
// what the lightmap was last made for, so it's only rebuilt when the light changes
|
||||||
|
#lightmap_daylight = -1;
|
||||||
|
|
||||||
|
// sky color and sky light for the time of day
|
||||||
|
#setup_sky(client: Client, partial_tick: number) {
|
||||||
|
const light = daylight(client.level.time + partial_tick);
|
||||||
|
const [r, g, b] = DAY_SKY.map((day, i) => NIGHT_SKY[i] + (day - NIGHT_SKY[i]) * light);
|
||||||
|
clear_background(r, g, b, 1);
|
||||||
|
|
||||||
|
if (Math.abs(light - this.#lightmap_daylight) > 0.001) {
|
||||||
|
this.#lightmap_daylight = light;
|
||||||
|
update_lightmap(NIGHT_SKY_LIGHT + (1 - NIGHT_SKY_LIGHT) * light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// partial_tick is how far this frame is between the last tick and the next, entities are drawn in between
|
// 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) {
|
render(client: Client, partial_tick: number) {
|
||||||
const camera = client.camera;
|
const camera = client.camera;
|
||||||
camera.setup(client.player, partial_tick);
|
camera.setup(client.player, partial_tick);
|
||||||
|
this.#setup_sky(client, partial_tick);
|
||||||
|
|
||||||
begin_mode_3d(camera);
|
begin_mode_3d(camera);
|
||||||
this.level_renderer.render_opaque(client.level, camera);
|
this.level_renderer.render_opaque(client.level, camera);
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export interface ClientContext {
|
|||||||
net: ClientNet;
|
net: ClientNet;
|
||||||
player: { readonly name: string; readonly position: Readonly<Position> };
|
player: { readonly name: string; readonly position: Readonly<Position> };
|
||||||
// read only, what this client sees
|
// read only, what this client sees
|
||||||
world: { get_block(x: number, y: number, z: number): Id | undefined };
|
world: {
|
||||||
|
get_block(x: number, y: number, z: number): Id | undefined;
|
||||||
|
// ticks since 06:00 of day 1, see ServerWorld.time
|
||||||
|
readonly time: number;
|
||||||
|
};
|
||||||
log(...args: unknown[]): void;
|
log(...args: unknown[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export interface ServerWorld {
|
|||||||
drop_item(x: number, y: number, z: number, item: ItemStack): void;
|
drop_item(x: number, y: number, z: number, item: ItemStack): void;
|
||||||
is_loaded(x: number, z: number): boolean;
|
is_loaded(x: number, z: number): boolean;
|
||||||
readonly seed: string;
|
readonly seed: string;
|
||||||
|
// ticks since 06:00 of day 1. a day is 36000 ticks (06:00 to 18:00), a night 12000
|
||||||
|
readonly time: number;
|
||||||
|
set_time(time: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
|
|||||||
+6
-2
@@ -7,7 +7,7 @@ export const AIR_ID = "bworld:air";
|
|||||||
|
|
||||||
// bump when a client and server of different versions can't play together.
|
// bump when a client and server of different versions can't play together.
|
||||||
// the server rejects a different version before the client downloads anything
|
// the server rejects a different version before the client downloads anything
|
||||||
export const PROTOCOL_VERSION = 2;
|
export const PROTOCOL_VERSION = 3;
|
||||||
|
|
||||||
export interface PlayerInfo {
|
export interface PlayerInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,6 +101,8 @@ export type ServerMessage =
|
|||||||
changes: BlockChange[];
|
changes: BlockChange[];
|
||||||
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
|
||||||
selected_slot: number;
|
selected_slot: number;
|
||||||
|
// world time in ticks, see common/time.ts
|
||||||
|
time: number;
|
||||||
}
|
}
|
||||||
| { type: "player_join"; player: PlayerInfo }
|
| { type: "player_join"; player: PlayerInfo }
|
||||||
| { type: "player_leave"; id: string }
|
| { type: "player_leave"; id: string }
|
||||||
@@ -120,7 +122,9 @@ export type ServerMessage =
|
|||||||
| { type: "cursor"; item: ItemData | null }
|
| { type: "cursor"; item: ItemData | null }
|
||||||
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
|
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
|
||||||
| { type: "screen_properties"; properties: Record<string, number> }
|
| { type: "screen_properties"; properties: Record<string, number> }
|
||||||
| { type: "close_screen" };
|
| { type: "close_screen" }
|
||||||
|
// the world time, every second and when it's changed. clients count ticks in between
|
||||||
|
| { type: "time"; time: number };
|
||||||
|
|
||||||
export const MAX_NAME_LENGTH = 16;
|
export const MAX_NAME_LENGTH = 16;
|
||||||
export const MAX_CHAT_LENGTH = 256;
|
export const MAX_CHAT_LENGTH = 256;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// the day and night cycle. world time counts ticks since 06:00 of day 1, it never wraps around
|
||||||
|
import { TICKS_PER_SECOND } from "./constants.ts";
|
||||||
|
|
||||||
|
// 30 minutes of day, 06:00 to 18:00
|
||||||
|
export const DAY_TICKS = 30 * 60 * TICKS_PER_SECOND;
|
||||||
|
// 10 minutes of night, 18:00 to 06:00
|
||||||
|
export const NIGHT_TICKS = 10 * 60 * TICKS_PER_SECOND;
|
||||||
|
export const CYCLE_TICKS = DAY_TICKS + NIGHT_TICKS;
|
||||||
|
// dusk right after sunset and dawn right before sunrise, so the whole day is bright
|
||||||
|
export const TWILIGHT_TICKS = 60 * TICKS_PER_SECOND;
|
||||||
|
|
||||||
|
// in game hours are longer during the day than at night, since both halves are 12 hours
|
||||||
|
const DAY_TICKS_PER_HOUR = DAY_TICKS / 12;
|
||||||
|
const NIGHT_TICKS_PER_HOUR = NIGHT_TICKS / 12;
|
||||||
|
// from midnight to 06:00, to count days from midnight
|
||||||
|
const TICKS_BEFORE_SIX = 6 * NIGHT_TICKS_PER_HOUR;
|
||||||
|
|
||||||
|
// named times of day, as ticks into the cycle
|
||||||
|
export const TIMES_OF_DAY: Record<string, number> = {
|
||||||
|
day: 0,
|
||||||
|
noon: 6 * DAY_TICKS_PER_HOUR,
|
||||||
|
sunset: DAY_TICKS,
|
||||||
|
night: DAY_TICKS + TWILIGHT_TICKS,
|
||||||
|
midnight: DAY_TICKS + 6 * NIGHT_TICKS_PER_HOUR,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ticks into the current cycle, 0 is 06:00
|
||||||
|
export function time_of_day(time: number): number {
|
||||||
|
return ((time % CYCLE_TICKS) + CYCLE_TICKS) % CYCLE_TICKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function is_day(time: number): boolean {
|
||||||
|
return time_of_day(time) < DAY_TICKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the day number, starting at 1 and going up at midnight, and the time on a 24 hour clock
|
||||||
|
export function clock(time: number): { day: number; hour: number; minute: number } {
|
||||||
|
const t = time_of_day(time);
|
||||||
|
const hours = t < DAY_TICKS ? 6 + t / DAY_TICKS_PER_HOUR : 18 + (t - DAY_TICKS) / NIGHT_TICKS_PER_HOUR;
|
||||||
|
const total_minutes = Math.floor(hours * 60) % (24 * 60);
|
||||||
|
return {
|
||||||
|
day: Math.floor((time + TICKS_BEFORE_SIX) / CYCLE_TICKS) + 1,
|
||||||
|
hour: Math.floor(total_minutes / 60),
|
||||||
|
minute: total_minutes % 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// like "Day 3, 14:05"
|
||||||
|
export function format_clock(time: number): string {
|
||||||
|
const { day, hour, minute } = clock(time);
|
||||||
|
return `Day ${day}, ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// how much daylight there is, 1 all day and 0 in the middle of the night, fading through dusk and dawn.
|
||||||
|
// time can be fractional, for drawing between ticks
|
||||||
|
export function daylight(time: number): number {
|
||||||
|
const t = time_of_day(time);
|
||||||
|
if (t < DAY_TICKS) return 1;
|
||||||
|
const into_night = t - DAY_TICKS;
|
||||||
|
const until_day = CYCLE_TICKS - t;
|
||||||
|
const fade = Math.min(1, into_night / TWILIGHT_TICKS, until_day / TWILIGHT_TICKS);
|
||||||
|
return 1 - smoothstep(fade);
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(x: number) {
|
||||||
|
return x * x * (3 - 2 * x);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "$/common/protocol.ts";
|
} from "$/common/protocol.ts";
|
||||||
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
|
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
|
||||||
import type { WorldgenSetup } from "$/common/generation.ts";
|
import type { WorldgenSetup } from "$/common/generation.ts";
|
||||||
|
import { CYCLE_TICKS, format_clock, time_of_day, TIMES_OF_DAY } from "$/common/time.ts";
|
||||||
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
|
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
|
||||||
import type { GameLoop } from "./game_loop.ts";
|
import type { GameLoop } from "./game_loop.ts";
|
||||||
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
|
||||||
@@ -62,6 +63,8 @@ export interface GameHost {
|
|||||||
export interface SavedWorld {
|
export interface SavedWorld {
|
||||||
version: 3;
|
version: 3;
|
||||||
seed: string;
|
seed: string;
|
||||||
|
// world time in ticks, 0 in saves from before the day and night cycle
|
||||||
|
time?: number;
|
||||||
changes: BlockChange[];
|
changes: BlockChange[];
|
||||||
tiles: SavedTile[];
|
tiles: SavedTile[];
|
||||||
// every ctx.containers container, by id
|
// every ctx.containers container, by id
|
||||||
@@ -124,6 +127,7 @@ export class GameServer {
|
|||||||
this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen);
|
this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen);
|
||||||
this.mods.storage = saved?.mod_storage ?? {};
|
this.mods.storage = saved?.mod_storage ?? {};
|
||||||
this.world.load_changes(saved?.changes ?? []);
|
this.world.load_changes(saved?.changes ?? []);
|
||||||
|
this.world.time = saved?.time ?? 0;
|
||||||
for (const [id, items] of Object.entries(saved?.containers ?? {})) {
|
for (const [id, items] of Object.entries(saved?.containers ?? {})) {
|
||||||
const container = new Container(items.length);
|
const container = new Container(items.length);
|
||||||
container.load(items);
|
container.load(items);
|
||||||
@@ -188,7 +192,11 @@ export class GameServer {
|
|||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
this.#tick += 1;
|
this.#tick += 1;
|
||||||
|
this.world.time += 1;
|
||||||
const second = this.#tick % TICKS_PER_SECOND === 0;
|
const second = this.#tick % TICKS_PER_SECOND === 0;
|
||||||
|
if (second) {
|
||||||
|
this.#broadcast({ type: "time", time: this.world.time });
|
||||||
|
}
|
||||||
|
|
||||||
for (const tile of [...this.world.tiles.values()]) {
|
for (const tile of [...this.world.tiles.values()]) {
|
||||||
if (tile.mod_data === undefined) {
|
if (tile.mod_data === undefined) {
|
||||||
@@ -236,6 +244,7 @@ export class GameServer {
|
|||||||
const saved: SavedWorld = {
|
const saved: SavedWorld = {
|
||||||
version: 3,
|
version: 3,
|
||||||
seed: this.world.seed,
|
seed: this.world.seed,
|
||||||
|
time: this.world.time,
|
||||||
changes: this.world.all_changes(),
|
changes: this.world.all_changes(),
|
||||||
tiles: [...this.world.tiles.values()].map((tile) => ({
|
tiles: [...this.world.tiles.values()].map((tile) => ({
|
||||||
id: tile.id,
|
id: tile.id,
|
||||||
@@ -342,6 +351,12 @@ export class GameServer {
|
|||||||
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
|
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set_time(time: number) {
|
||||||
|
this.world.time = time;
|
||||||
|
this.world.dirty = true;
|
||||||
|
this.#broadcast({ type: "time", time });
|
||||||
|
}
|
||||||
|
|
||||||
// changes a block's state bits without anything else happening, see ServerWorld.get_block_value
|
// changes a block's state bits without anything else happening, see ServerWorld.get_block_value
|
||||||
set_block_state(x: number, y: number, z: number, state: number) {
|
set_block_state(x: number, y: number, z: number, state: number) {
|
||||||
const id = this.world.get_block_id(x, y, z);
|
const id = this.world.get_block_id(x, y, z);
|
||||||
@@ -511,6 +526,7 @@ export class GameServer {
|
|||||||
entities: [...this.#entities.values()].map((entity) => entity.info()),
|
entities: [...this.#entities.values()].map((entity) => entity.info()),
|
||||||
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
|
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
|
||||||
selected_slot: player.selected_slot,
|
selected_slot: player.selected_slot,
|
||||||
|
time: this.world.time,
|
||||||
});
|
});
|
||||||
this.#players.set(conn, player);
|
this.#players.set(conn, player);
|
||||||
this.#sync(player);
|
this.#sync(player);
|
||||||
@@ -820,6 +836,10 @@ export class GameServer {
|
|||||||
this.give(player, item_id, amount);
|
this.give(player, item_id, amount);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (command === "time") {
|
||||||
|
this.#time_command(player, args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (command === "tps") {
|
if (command === "tps") {
|
||||||
if (!this.loop) {
|
if (!this.loop) {
|
||||||
this.#send(player, { type: "chat", text: "The game loop isn't running" });
|
this.#send(player, { type: "chat", text: "The game loop isn't running" });
|
||||||
@@ -841,6 +861,30 @@ export class GameServer {
|
|||||||
this.#send(player, { type: "chat", text: `Unknown command /${command}` });
|
this.#send(player, { type: "chat", text: `Unknown command /${command}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /time shows the time, /time set <ticks or day, noon, sunset, night, midnight> changes it, /time add <ticks>
|
||||||
|
#time_command(player: ServerPlayer, [action, value]: string[]) {
|
||||||
|
const usage = "Usage: /time [set <ticks|day|noon|sunset|night|midnight> | add <ticks>]";
|
||||||
|
if (action === undefined) {
|
||||||
|
this.#send(player, { type: "chat", text: `${format_clock(this.world.time)} (tick ${this.world.time})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticks = Number(value);
|
||||||
|
if (action === "set" && value !== undefined && value in TIMES_OF_DAY) {
|
||||||
|
// the next time it's that time of day, so days keep counting up
|
||||||
|
const now = this.world.time;
|
||||||
|
const target = now - time_of_day(now) + TIMES_OF_DAY[value];
|
||||||
|
this.set_time(target >= now ? target : target + CYCLE_TICKS);
|
||||||
|
} else if (action === "set" && Number.isInteger(ticks) && ticks >= 0) {
|
||||||
|
this.set_time(ticks);
|
||||||
|
} else if (action === "add" && Number.isInteger(ticks) && this.world.time + ticks >= 0) {
|
||||||
|
this.set_time(this.world.time + ticks);
|
||||||
|
} else {
|
||||||
|
this.#send(player, { type: "chat", text: usage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#broadcast({ type: "chat", text: `${player.name} set the time to ${format_clock(this.world.time)}` });
|
||||||
|
}
|
||||||
|
|
||||||
#close_screen(player: ServerPlayer) {
|
#close_screen(player: ServerPlayer) {
|
||||||
const screen = player.screen;
|
const screen = player.screen;
|
||||||
player.screen = undefined;
|
player.screen = undefined;
|
||||||
|
|||||||
@@ -310,7 +310,9 @@ export class ModRuntime {
|
|||||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||||
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
|
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
|
||||||
}
|
}
|
||||||
if (["give", "tps"].includes(name)) throw new ModLoadError(mod, `/${name} belongs to the engine`);
|
if (["give", "tps", "time"].includes(name)) {
|
||||||
|
throw new ModLoadError(mod, `/${name} belongs to the engine`);
|
||||||
|
}
|
||||||
const existing = this.commands.get(name);
|
const existing = this.commands.get(name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
|
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
|
||||||
@@ -363,6 +365,15 @@ export class ModRuntime {
|
|||||||
get seed() {
|
get seed() {
|
||||||
return game().world.seed;
|
return game().world.seed;
|
||||||
},
|
},
|
||||||
|
get time() {
|
||||||
|
return game().world.time;
|
||||||
|
},
|
||||||
|
set_time: (time) => {
|
||||||
|
if (!Number.isInteger(time) || time < 0) {
|
||||||
|
throw new Error(`time must be a whole number of ticks, not ${time}`);
|
||||||
|
}
|
||||||
|
game().set_time(time);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
players: {
|
players: {
|
||||||
all: () => game().players().map((p) => this.player(p)),
|
all: () => game().players().map((p) => this.player(p)),
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class ServerWorld {
|
|||||||
#changes = new Map<number, Map<string, BlockChange>>();
|
#changes = new Map<number, Map<string, BlockChange>>();
|
||||||
tiles = new Map<string, Tile>();
|
tiles = new Map<string, Tile>();
|
||||||
|
|
||||||
|
// ticks since 06:00 of day 1, see common/time.ts
|
||||||
|
time = 0;
|
||||||
|
|
||||||
// set when anything that gets saved changes
|
// set when anything that gets saved changes
|
||||||
dirty = false;
|
dirty = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { clock, CYCLE_TICKS, DAY_TICKS, daylight, format_clock, is_day, NIGHT_TICKS } from "$/common/time.ts";
|
||||||
|
import { test_game } from "./helpers.ts";
|
||||||
|
|
||||||
|
Deno.test("a day is 30 minutes and a night 10, tick 0 is 06:00 of day 1", () => {
|
||||||
|
assertEquals(DAY_TICKS, 30 * 60 * 20);
|
||||||
|
assertEquals(NIGHT_TICKS, 10 * 60 * 20);
|
||||||
|
assertEquals(format_clock(0), "Day 1, 06:00");
|
||||||
|
assertEquals(format_clock(DAY_TICKS / 2), "Day 1, 12:00");
|
||||||
|
assertEquals(format_clock(DAY_TICKS), "Day 1, 18:00");
|
||||||
|
// midnight is halfway through the night, and the day number goes up there
|
||||||
|
assertEquals(format_clock(DAY_TICKS + NIGHT_TICKS / 2 - 1), "Day 1, 23:59");
|
||||||
|
assertEquals(format_clock(DAY_TICKS + NIGHT_TICKS / 2), "Day 2, 00:00");
|
||||||
|
assertEquals(format_clock(CYCLE_TICKS), "Day 2, 06:00");
|
||||||
|
assertEquals(clock(3 * CYCLE_TICKS + DAY_TICKS / 4), { day: 4, hour: 9, minute: 0 });
|
||||||
|
assert(is_day(DAY_TICKS - 1) && !is_day(DAY_TICKS) && is_day(CYCLE_TICKS));
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("it's bright all day, dark in the middle of the night, and fades in between", () => {
|
||||||
|
assertEquals(daylight(0), 1);
|
||||||
|
assertEquals(daylight(DAY_TICKS - 1), 1);
|
||||||
|
assertEquals(daylight(DAY_TICKS + NIGHT_TICKS / 2), 0);
|
||||||
|
const dusk = daylight(DAY_TICKS + 600);
|
||||||
|
const dawn = daylight(CYCLE_TICKS - 600);
|
||||||
|
assert(dusk > 0 && dusk < 1 && Math.abs(dusk - dawn) < 1e-9, `dusk ${dusk}, dawn ${dawn}`);
|
||||||
|
// smooth between ticks too
|
||||||
|
assert(daylight(DAY_TICKS + 600.5) < dusk);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("the server keeps the time, tells players, saves it, and /time changes it", async () => {
|
||||||
|
const { game, join, take, send } = await test_game("mods");
|
||||||
|
assertEquals(join(1, "alice").time, 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) game.tick();
|
||||||
|
assertEquals(take(1).filter((m) => m.type === "time").map((m) => m.time), [20, 40]);
|
||||||
|
|
||||||
|
send(1, { type: "chat", text: "/time" });
|
||||||
|
assertEquals(take(1).find((m) => m.type === "chat").text, "Day 1, 06:00 (tick 40)");
|
||||||
|
|
||||||
|
send(1, { type: "chat", text: "/time set midnight" });
|
||||||
|
let messages = take(1);
|
||||||
|
assertEquals(messages.find((m) => m.type === "time").time, DAY_TICKS + NIGHT_TICKS / 2);
|
||||||
|
assert(messages.some((m) => m.text === "alice set the time to Day 2, 00:00"));
|
||||||
|
|
||||||
|
// named times are the next one, so time never goes backwards
|
||||||
|
send(1, { type: "chat", text: "/time set day" });
|
||||||
|
assertEquals(game.world.time, CYCLE_TICKS);
|
||||||
|
send(1, { type: "chat", text: "/time add 100" });
|
||||||
|
assertEquals(game.world.time, CYCLE_TICKS + 100);
|
||||||
|
send(1, { type: "chat", text: "/time set nope" });
|
||||||
|
messages = take(1);
|
||||||
|
assert(messages.at(-1).text.startsWith("Usage: /time"));
|
||||||
|
|
||||||
|
const loaded = await test_game("mods", game.save());
|
||||||
|
assertEquals(loaded.game.world.time, CYCLE_TICKS + 100);
|
||||||
|
assertEquals(loaded.join(1, "alice").time, CYCLE_TICKS + 100);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user